From f16886041d26616bf49d1e39cb3ef85a21e20ccd Mon Sep 17 00:00:00 2001 From: ziggie Date: Tue, 18 Nov 2025 23:35:02 +0100 Subject: [PATCH 001/134] graph: fix graph cache population for channels with both policies disabled Fix a bug where channels with both policies disabled were not added to the graph cache during startup. When a policy update later re-enabled one of the directions, the update would succeed in the database but fail to update the graph cache (since the channel structure was never added), preventing the channel from being used for routing. --- graph/db/graph_cache.go | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/graph/db/graph_cache.go b/graph/db/graph_cache.go index a691361a2..593483747 100644 --- a/graph/db/graph_cache.go +++ b/graph/db/graph_cache.go @@ -121,13 +121,9 @@ func (c *GraphCache) AddChannel(info *models.CachedEdgeInfo, return } - if policy1 != nil && policy1.IsDisabled() && - policy2 != nil && policy2.IsDisabled() { - - return - } - - // Create the edge entry for both nodes. + // Create the edge entry for both nodes. We always add the channel + // structure to the cache, even if both policies are currently disabled, + // so that later policy updates can find and update the channel entry. c.mtx.Lock() c.updateOrAddEdge(info.NodeKey1Bytes, &DirectedChannel{ ChannelID: info.ChannelID, @@ -143,6 +139,19 @@ func (c *GraphCache) AddChannel(info *models.CachedEdgeInfo, }) c.mtx.Unlock() + // Skip adding policies if both are disabled, as the channel is + // currently unusable for routing. However, we still add the channel + // structure above so that policy updates can later enable it. + if policy1 != nil && policy1.IsDisabled() && + policy2 != nil && policy2.IsDisabled() { + + log.Debugf("Skipping policies for channel %v: both "+ + "policies are disabled (channel structure still "+ + "cached for future updates)", info.ChannelID) + + return + } + // The policy's node is always the to_node. So if policy 1 has to_node // of node 2 then we have the policy 1 as seen from node 1. if policy1 != nil { From 8bc240770a5e91681814b760b54a38f4f997d4a8 Mon Sep 17 00:00:00 2001 From: ziggie Date: Wed, 19 Nov 2025 10:32:04 +0100 Subject: [PATCH 002/134] graph: add regression test for the fixed behaviour --- graph/db/graph_cache_test.go | 110 +++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/graph/db/graph_cache_test.go b/graph/db/graph_cache_test.go index 43c35862e..89e3a7e87 100644 --- a/graph/db/graph_cache_test.go +++ b/graph/db/graph_cache_test.go @@ -139,3 +139,113 @@ func assertCachedPolicyEqual(t *testing.T, original, require.Equal(t, original.ToNodePubKey(), cached.ToNodePubKey()) } } + +// TestGraphCacheDisabledPoliciesRegression is a regression test for the bug +// where channels with both policies disabled were not added to the graph cache +// during population, preventing future policy updates from working. +// +// The bug flow was: +// 1. Channel with both policies disabled exists in DB. +// 2. populateCache skips adding it to graph cache entirely. +// 3. Later, a policy update arrives enabling one direction. +// 4. UpdateEdgePolicy updates the DB successfully. +// 5. UpdateEdgePolicy tries to update graph cache but channel not found. +// 6. Channel never becomes usable for routing. +func TestGraphCacheDisabledPoliciesRegression(t *testing.T) { + t.Parallel() + + // Create a simple cache instance. + cache := NewGraphCache(10) + + // Simulate a channel with both policies disabled. + chanID := uint64(12345) + node1 := pubKey1 + node2 := pubKey2 + + edgeInfo := &models.CachedEdgeInfo{ + ChannelID: chanID, + NodeKey1Bytes: node1, + NodeKey2Bytes: node2, + Capacity: 1000000, + } + + // Create two disabled policies. + disabledPolicy1 := &models.CachedEdgePolicy{ + ChannelID: chanID, + ChannelFlags: lnwire.ChanUpdateDisabled, + } + disabledPolicy2 := &models.CachedEdgePolicy{ + ChannelID: chanID, + ChannelFlags: lnwire.ChanUpdateDisabled | + lnwire.ChanUpdateDirection, + } + + // Add the channel with both policies disabled (simulating + // populateCache). + cache.AddChannel(edgeInfo, disabledPolicy1, disabledPolicy2) + + // Verify the channel structure was added to cache. + var foundChannels []*DirectedChannel + err := cache.ForEachChannel(node1, func(c *DirectedChannel) error { + if c.ChannelID == chanID { + foundChannels = append(foundChannels, c) + } + + return nil + }) + require.NoError(t, err) + require.Len(t, foundChannels, 1, + "channel structure should be in cache even when both "+ + "policies are disabled") + + // Verify policies were NOT added (both disabled). + require.False(t, foundChannels[0].OutPolicySet, + "disabled outgoing policy should not be set in cache") + require.Nil(t, foundChannels[0].InPolicy, + "disabled incoming policy should not be set in cache") + + // Now simulate receiving a fresh update enabling one direction. + enabledPolicy1 := &models.CachedEdgePolicy{ + ChannelID: chanID, + ChannelFlags: 0, // NOT disabled anymore + TimeLockDelta: 40, + MinHTLC: lnwire.MilliSatoshi(1000), + } + + // Update the policy (simulating what UpdateEdgePolicy does). + cache.UpdatePolicy(enabledPolicy1, node1, node2) + + // Verify the policy update succeeded. Before the fix, UpdatePolicy + // would log "Channel not found in graph cache" and return early, + // so the policy would never be added. + foundChannels = nil + err = cache.ForEachChannel(node1, func(c *DirectedChannel) error { + if c.ChannelID == chanID { + foundChannels = append(foundChannels, c) + } + + return nil + }) + require.NoError(t, err) + require.Len(t, foundChannels, 1) + + // The policy should now be set. + require.True(t, foundChannels[0].OutPolicySet, + "REGRESSION: policy update should work even for channels that "+ + "had both policies disabled initially") + + // Verify we can also see it from node2's perspective. + foundChannels = nil + err = cache.ForEachChannel(node2, func(c *DirectedChannel) error { + if c.ChannelID == chanID { + foundChannels = append(foundChannels, c) + } + + return nil + }) + require.NoError(t, err) + require.Len(t, foundChannels, 1) + require.NotNil(t, foundChannels[0].InPolicy, + "incoming policy should be set after policy update") + require.Equal(t, uint16(40), foundChannels[0].InPolicy.TimeLockDelta) +} From da55e567e99bfef50fa4031fdbcb52169fbf0a53 Mon Sep 17 00:00:00 2001 From: ziggie Date: Wed, 19 Nov 2025 10:11:40 +0100 Subject: [PATCH 003/134] docs: add release-notes for 20.1 --- docs/release-notes/release-notes-0.20.1.md | 61 ++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 docs/release-notes/release-notes-0.20.1.md diff --git a/docs/release-notes/release-notes-0.20.1.md b/docs/release-notes/release-notes-0.20.1.md new file mode 100644 index 000000000..1963db61b --- /dev/null +++ b/docs/release-notes/release-notes-0.20.1.md @@ -0,0 +1,61 @@ +# Release Notes +- [Bug Fixes](#bug-fixes) +- [New Features](#new-features) + - [Functional Enhancements](#functional-enhancements) + - [RPC Additions](#rpc-additions) + - [lncli Additions](#lncli-additions) +- [Improvements](#improvements) + - [Functional Updates](#functional-updates) + - [RPC Updates](#rpc-updates) + - [lncli Updates](#lncli-updates) + - [Breaking Changes](#breaking-changes) + - [Performance Improvements](#performance-improvements) + - [Deprecations](#deprecations) +- [Technical and Architectural Updates](#technical-and-architectural-updates) + - [BOLT Spec Updates](#bolt-spec-updates) + - [Testing](#testing) + - [Database](#database) + - [Code Health](#code-health) + - [Tooling and Documentation](#tooling-and-documentation) +- [Contributors (Alphabetical Order)](#contributors) + +# Bug Fixes + +* Fix bug where channels with both [policies disabled at startup could never + be used for routing](https://github.com/lightningnetwork/lnd/pull/10378) + +# New Features + +## Functional Enhancements + +## RPC Additions + +## lncli Additions + +# Improvements +## Functional Updates + +## RPC Updates + +## lncli Updates + +## Breaking Changes + +## Performance Improvements + +## Deprecations + +# Technical and Architectural Updates +## BOLT Spec Updates + +## Testing + +## Database + +## Code Health + +## Tooling and Documentation + +# Contributors (Alphabetical Order) + +* Ziggie From dac47cf698e7041da26df83c49f653c9a0c9ced3 Mon Sep 17 00:00:00 2001 From: ziggie Date: Fri, 21 Nov 2025 23:57:40 +0100 Subject: [PATCH 004/134] contracourt: rename broadcastHeight to confirmHeight The broadcastHeight was misleading because the commit resolver is only created when the commitment transaction is confirmed. --- contractcourt/briefcase_test.go | 8 ++++---- contractcourt/commit_sweep_resolver.go | 22 +++++++++++----------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/contractcourt/briefcase_test.go b/contractcourt/briefcase_test.go index 3dfc155e4..c86bffb38 100644 --- a/contractcourt/briefcase_test.go +++ b/contractcourt/briefcase_test.go @@ -278,9 +278,9 @@ func assertResolversEqual(t *testing.T, originalResolver ContractResolver, t.Fatalf("expected %v, got %v", ogRes.resolved.Load(), diskRes.resolved.Load()) } - if ogRes.broadcastHeight != diskRes.broadcastHeight { + if ogRes.confirmHeight != diskRes.confirmHeight { t.Fatalf("expected %v, got %v", - ogRes.broadcastHeight, diskRes.broadcastHeight) + ogRes.confirmHeight, diskRes.confirmHeight) } if ogRes.chanPoint != diskRes.chanPoint { t.Fatalf("expected %v, got %v", ogRes.chanPoint, @@ -341,8 +341,8 @@ func TestContractInsertionRetrieval(t *testing.T) { SelfOutputSignDesc: testSignDesc, MaturityDelay: 99, }, - broadcastHeight: 109, - chanPoint: testChanPoint1, + confirmHeight: 109, + chanPoint: testChanPoint1, } commitResolver.resolved.Store(false) diff --git a/contractcourt/commit_sweep_resolver.go b/contractcourt/commit_sweep_resolver.go index 0f2cb6b24..04dce47fa 100644 --- a/contractcourt/commit_sweep_resolver.go +++ b/contractcourt/commit_sweep_resolver.go @@ -38,10 +38,10 @@ type commitSweepResolver struct { // this HTLC on-chain. commitResolution lnwallet.CommitOutputResolution - // broadcastHeight is the height that the original contract was - // broadcast to the main-chain at. We'll use this value to bound any - // historical queries to the chain for spends/confirmations. - broadcastHeight uint32 + // confirmHeight is the block height that the commitment transaction was + // confirmed at. We'll use this value to bound any historical queries to + // the chain for spends/confirmations. + confirmHeight uint32 // chanPoint is the channel point of the original contract. chanPoint wire.OutPoint @@ -74,13 +74,13 @@ type commitSweepResolver struct { // newCommitSweepResolver instantiates a new direct commit output resolver. func newCommitSweepResolver(res lnwallet.CommitOutputResolution, - broadcastHeight uint32, chanPoint wire.OutPoint, + confirmHeight uint32, chanPoint wire.OutPoint, resCfg ResolverConfig) *commitSweepResolver { r := &commitSweepResolver{ contractResolverKit: *newContractResolverKit(resCfg), commitResolution: res, - broadcastHeight: broadcastHeight, + confirmHeight: confirmHeight, chanPoint: chanPoint, } @@ -133,7 +133,7 @@ func (c *commitSweepResolver) getCommitTxConfHeight() (uint32, error) { const confDepth = 1 confChan, err := c.Notifier.RegisterConfirmationsNtfn( - &txID, pkScript, confDepth, c.broadcastHeight, + &txID, pkScript, confDepth, c.confirmHeight, ) if err != nil { return 0, err @@ -268,7 +268,7 @@ func (c *commitSweepResolver) Encode(w io.Writer) error { if err := binary.Write(w, endian, c.IsResolved()); err != nil { return err } - if err := binary.Write(w, endian, c.broadcastHeight); err != nil { + if err := binary.Write(w, endian, c.confirmHeight); err != nil { return err } if _, err := w.Write(c.chanPoint.Hash[:]); err != nil { @@ -308,7 +308,7 @@ func newCommitSweepResolverFromReader(r io.Reader, resCfg ResolverConfig) ( c.markResolved() } - if err := binary.Read(r, endian, &c.broadcastHeight); err != nil { + if err := binary.Read(r, endian, &c.confirmHeight); err != nil { return nil, err } _, err := io.ReadFull(r, c.chanPoint.Hash[:]) @@ -412,7 +412,7 @@ func (c *commitSweepResolver) Launch() error { inp = input.NewCsvInputWithCltv( &c.commitResolution.SelfOutPoint, witnessType, &c.commitResolution.SelfOutputSignDesc, - c.broadcastHeight, c.commitResolution.MaturityDelay, + c.confirmHeight, c.commitResolution.MaturityDelay, c.leaseExpiry, input.WithResolutionBlob( c.commitResolution.ResolutionBlob, ), @@ -421,7 +421,7 @@ func (c *commitSweepResolver) Launch() error { inp = input.NewCsvInput( &c.commitResolution.SelfOutPoint, witnessType, &c.commitResolution.SelfOutputSignDesc, - c.broadcastHeight, c.commitResolution.MaturityDelay, + c.confirmHeight, c.commitResolution.MaturityDelay, input.WithResolutionBlob( c.commitResolution.ResolutionBlob, ), From c4d8dc8e0011d3ea21f363d0b5e1ab22c2b81418 Mon Sep 17 00:00:00 2001 From: ziggie Date: Sat, 22 Nov 2025 00:06:36 +0100 Subject: [PATCH 005/134] contracourt: fix comment --- contractcourt/commit_sweep_resolver.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contractcourt/commit_sweep_resolver.go b/contractcourt/commit_sweep_resolver.go index 04dce47fa..ea3b06199 100644 --- a/contractcourt/commit_sweep_resolver.go +++ b/contractcourt/commit_sweep_resolver.go @@ -393,7 +393,7 @@ func (c *commitSweepResolver) Launch() error { unlockHeight = max(unlockHeight, c.leaseExpiry) } - // Update report now that we learned the confirmation height. + // Update report with the calculated maturity height. c.reportLock.Lock() c.currentReport.MaturityHeight = unlockHeight c.reportLock.Unlock() From 856693d70c85a0acf5697ac68e8900dda93fc62a Mon Sep 17 00:00:00 2001 From: ziggie Date: Sat, 22 Nov 2025 00:06:53 +0100 Subject: [PATCH 006/134] contractcourt: use confheight instead of rescanning the chain --- contractcourt/commit_sweep_resolver.go | 38 +-------------- contractcourt/commit_sweep_resolver_test.go | 51 ++++++++++----------- 2 files changed, 25 insertions(+), 64 deletions(-) diff --git a/contractcourt/commit_sweep_resolver.go b/contractcourt/commit_sweep_resolver.go index ea3b06199..d8c8c3903 100644 --- a/contractcourt/commit_sweep_resolver.go +++ b/contractcourt/commit_sweep_resolver.go @@ -123,37 +123,6 @@ func waitForSpend(op *wire.OutPoint, pkScript []byte, heightHint uint32, } } -// getCommitTxConfHeight waits for confirmation of the commitment tx and -// returns the confirmation height. -func (c *commitSweepResolver) getCommitTxConfHeight() (uint32, error) { - txID := c.commitResolution.SelfOutPoint.Hash - signDesc := c.commitResolution.SelfOutputSignDesc - pkScript := signDesc.Output.PkScript - - const confDepth = 1 - - confChan, err := c.Notifier.RegisterConfirmationsNtfn( - &txID, pkScript, confDepth, c.confirmHeight, - ) - if err != nil { - return 0, err - } - defer confChan.Cancel() - - select { - case txConfirmation, ok := <-confChan.Confirmed: - if !ok { - return 0, fmt.Errorf("cannot get confirmation "+ - "for commit tx %v", txID) - } - - return txConfirmation.BlockHeight, nil - - case <-c.quit: - return 0, errResolverShuttingDown - } -} - // Resolve instructs the contract resolver to resolve the output on-chain. Once // the output has been *fully* resolved, the function should return immediately // with a nil ContractResolver value for the first return value. In the case @@ -381,14 +350,9 @@ func (c *commitSweepResolver) Launch() error { return nil } - confHeight, err := c.getCommitTxConfHeight() - if err != nil { - return err - } - // Wait up until the CSV expires, unless we also have a CLTV that // expires after. - unlockHeight := confHeight + c.commitResolution.MaturityDelay + unlockHeight := c.confirmHeight + c.commitResolution.MaturityDelay if c.hasCLTV() { unlockHeight = max(unlockHeight, c.leaseExpiry) } diff --git a/contractcourt/commit_sweep_resolver_test.go b/contractcourt/commit_sweep_resolver_test.go index 6855fddcd..5c660e100 100644 --- a/contractcourt/commit_sweep_resolver_test.go +++ b/contractcourt/commit_sweep_resolver_test.go @@ -18,6 +18,10 @@ import ( "github.com/stretchr/testify/require" ) +const ( + testCommitSweepConfHeight = 99 +) + type commitSweepResolverTestContext struct { resolver *commitSweepResolver notifier *mock.ChainNotifier @@ -27,7 +31,8 @@ type commitSweepResolverTestContext struct { } func newCommitSweepResolverTestContext(t *testing.T, - resolution *lnwallet.CommitOutputResolution) *commitSweepResolverTestContext { + resolution *lnwallet.CommitOutputResolution, + confirmHeight uint32) *commitSweepResolverTestContext { notifier := &mock.ChainNotifier{ EpochChan: make(chan *chainntnfs.BlockEpoch), @@ -68,7 +73,7 @@ func newCommitSweepResolverTestContext(t *testing.T, } resolver := newCommitSweepResolver( - *resolution, 0, wire.OutPoint{}, cfg, + *resolution, confirmHeight, wire.OutPoint{}, cfg, ) return &commitSweepResolverTestContext{ @@ -178,7 +183,9 @@ func TestCommitSweepResolverNoDelay(t *testing.T) { }, } - ctx := newCommitSweepResolverTestContext(t, &res) + ctx := newCommitSweepResolverTestContext( + t, &res, testCommitSweepConfHeight, + ) // Replace our checkpoint with one which will push reports into a // channel for us to consume. We replace this function on the resolver @@ -197,15 +204,12 @@ func TestCommitSweepResolverNoDelay(t *testing.T) { ctx.resolve() - spendTx := &wire.MsgTx{} - spendHash := spendTx.TxHash() - ctx.notifier.ConfChan <- &chainntnfs.TxConfirmation{ - Tx: spendTx, - } - // No csv delay, so the input should be swept immediately. <-ctx.sweeper.sweptInputs + spendTx := &wire.MsgTx{} + spendHash := spendTx.TxHash() + amt := btcutil.Amount(res.SelfOutputSignDesc.Output.Value) expectedReport := &channeldb.ResolverReport{ OutPoint: wire.OutPoint{}, @@ -242,7 +246,10 @@ func testCommitSweepResolverDelay(t *testing.T, sweepErr error) { SelfOutPoint: outpoint, } - ctx := newCommitSweepResolverTestContext(t, &res) + // Use confirmHeight = 99, so maturityHeight = 99 + 3 = 102. + ctx := newCommitSweepResolverTestContext( + t, &res, testCommitSweepConfHeight, + ) // Replace our checkpoint with one which will push reports into a // channel for us to consume. We replace this function on the resolver @@ -270,25 +277,18 @@ func testCommitSweepResolverDelay(t *testing.T, sweepErr error) { Amount: btcutil.Amount(amt), LimboBalance: btcutil.Amount(amt), } - if *report != expectedReport { - t.Fatalf("unexpected resolver report. want=%v got=%v", - expectedReport, report) - } + require.Equal(t, expectedReport, *report) ctx.resolve() - ctx.notifier.ConfChan <- &chainntnfs.TxConfirmation{ - BlockHeight: testInitialBlockHeight - 1, - } - - // Allow resolver to process confirmation. + // Allow resolver to launch and update the report. time.Sleep(sweepProcessInterval) // Expect report to be updated. + // confirmHeight(99) + maturityDelay(3) = 102. report = ctx.resolver.report() - if report.MaturityHeight != testInitialBlockHeight+2 { - t.Fatal("report maturity height incorrect") - } + expectedMaturity := testCommitSweepConfHeight + res.MaturityDelay + require.Equal(t, expectedMaturity, report.MaturityHeight) // Notify initial block height. Although the csv lock is still in // effect, we expect the input being sent to the sweeper before the csv @@ -325,13 +325,10 @@ func testCommitSweepResolverDelay(t *testing.T, sweepErr error) { Outpoint: outpoint, Type: ReportOutputUnencumbered, Amount: btcutil.Amount(amt), - MaturityHeight: testInitialBlockHeight + 2, + MaturityHeight: testCommitSweepConfHeight + res.MaturityDelay, RecoveredBalance: expectedRecoveredBalance, } - if *report != expectedReport { - t.Fatalf("unexpected resolver report. want=%v got=%v", - expectedReport, report) - } + require.Equal(t, expectedReport, *report) } // TestCommitSweepResolverDelay tests resolution of a direct commitment output From 465013f0978fac0f3b2556a0845c6dafd9eae563 Mon Sep 17 00:00:00 2001 From: ziggie Date: Sat, 22 Nov 2025 00:10:16 +0100 Subject: [PATCH 007/134] docs: add release-notes for LND 20.1 --- docs/release-notes/release-notes-0.20.1.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/release-notes/release-notes-0.20.1.md b/docs/release-notes/release-notes-0.20.1.md index 1963db61b..7dbcd31ea 100644 --- a/docs/release-notes/release-notes-0.20.1.md +++ b/docs/release-notes/release-notes-0.20.1.md @@ -24,6 +24,10 @@ * Fix bug where channels with both [policies disabled at startup could never be used for routing](https://github.com/lightningnetwork/lnd/pull/10378) +* [Fix a case where resolving the + to_local/to_remote output](https://github.com/lightningnetwork/lnd/pull/10387) + might take too long. + # New Features ## Functional Enhancements From 07003fc3a04a0f2f54a4c17d09adab7f0be2d5b1 Mon Sep 17 00:00:00 2001 From: ffranr Date: Mon, 24 Nov 2025 16:19:48 +0000 Subject: [PATCH 008/134] lnwallet: add field `CommitTxBlockHeight` to ResolutionReq Introduce `CommitTxBlockHeight` field to the `ResolutionReq` structure and related methods. This field records the block height where a commitment transaction has confirmed. (cherry picked from commit 3d2b0d703ed7e7327802b3efd1b6f579919e2cb1) --- contractcourt/chain_watcher.go | 3 +- lnwallet/aux_resolutions.go | 4 + lnwallet/channel.go | 320 ++++++++++++++++++--------------- 3 files changed, 177 insertions(+), 150 deletions(-) diff --git a/contractcourt/chain_watcher.go b/contractcourt/chain_watcher.go index 9c566fd6b..082b47228 100644 --- a/contractcourt/chain_watcher.go +++ b/contractcourt/chain_watcher.go @@ -1061,7 +1061,8 @@ func (c *chainWatcher) dispatchLocalForceClose( "detected", c.cfg.chanState.FundingOutpoint) forceClose, err := lnwallet.NewLocalForceCloseSummary( - c.cfg.chanState, c.cfg.signer, commitSpend.SpendingTx, stateNum, + c.cfg.chanState, c.cfg.signer, commitSpend.SpendingTx, + uint32(commitSpend.SpendingHeight), stateNum, c.cfg.auxLeafStore, c.cfg.auxResolver, ) if err != nil { diff --git a/lnwallet/aux_resolutions.go b/lnwallet/aux_resolutions.go index b36e2d636..14802c57c 100644 --- a/lnwallet/aux_resolutions.go +++ b/lnwallet/aux_resolutions.go @@ -77,6 +77,10 @@ type ResolutionReq struct { // CommitTx is the force close commitment transaction. CommitTx *wire.MsgTx + // CommitTxBlockHeight is the block height where the commitment + // transaction confirmed. It is 0 if unknown or not confirmed yet. + CommitTxBlockHeight uint32 + // CommitFee is the fee that was paid for the commitment transaction. CommitFee btcutil.Amount diff --git a/lnwallet/channel.go b/lnwallet/channel.go index c96a35b45..484a019da 100644 --- a/lnwallet/channel.go +++ b/lnwallet/channel.go @@ -2215,20 +2215,23 @@ func NewBreachRetribution(chanState *channeldb.OpenChannel, stateNum uint64, // At this point, we'll check to see if we need any extra // resolution data for this output. + // + //nolint:ll resolveReq := ResolutionReq{ - ChanPoint: chanState.FundingOutpoint, - ChanType: chanState.ChanType, - ShortChanID: chanState.ShortChanID(), - Initiator: chanState.IsInitiator, - FundingBlob: chanState.CustomBlob, - Type: input.TaprootRemoteCommitSpend, - CloseType: Breach, - CommitTx: spendTx, - SignDesc: *br.LocalOutputSignDesc, - KeyRing: keyRing, - CsvDelay: ourDelay, - BreachCsvDelay: fn.Some(theirDelay), - CommitFee: chanState.RemoteCommitment.CommitFee, + ChanPoint: chanState.FundingOutpoint, + ChanType: chanState.ChanType, + ShortChanID: chanState.ShortChanID(), + Initiator: chanState.IsInitiator, + FundingBlob: chanState.CustomBlob, + Type: input.TaprootRemoteCommitSpend, + CloseType: Breach, + CommitTx: spendTx, + CommitTxBlockHeight: breachHeight, + SignDesc: *br.LocalOutputSignDesc, + KeyRing: keyRing, + CsvDelay: ourDelay, + BreachCsvDelay: fn.Some(theirDelay), + CommitFee: chanState.RemoteCommitment.CommitFee, } if revokedLog != nil { resolveReq.CommitBlob = revokedLog.CustomBlob.ValOpt() @@ -2295,20 +2298,23 @@ func NewBreachRetribution(chanState *channeldb.OpenChannel, stateNum uint64, // At this point, we'll check to see if we need any extra // resolution data for this output. + // + //nolint:ll resolveReq := ResolutionReq{ - ChanPoint: chanState.FundingOutpoint, - ChanType: chanState.ChanType, - ShortChanID: chanState.ShortChanID(), - Initiator: chanState.IsInitiator, - FundingBlob: chanState.CustomBlob, - Type: input.TaprootCommitmentRevoke, - CloseType: Breach, - CommitTx: spendTx, - SignDesc: *br.RemoteOutputSignDesc, - KeyRing: keyRing, - CsvDelay: theirDelay, - BreachCsvDelay: fn.Some(theirDelay), - CommitFee: chanState.RemoteCommitment.CommitFee, + ChanPoint: chanState.FundingOutpoint, + ChanType: chanState.ChanType, + ShortChanID: chanState.ShortChanID(), + Initiator: chanState.IsInitiator, + FundingBlob: chanState.CustomBlob, + Type: input.TaprootCommitmentRevoke, + CloseType: Breach, + CommitTx: spendTx, + CommitTxBlockHeight: breachHeight, + SignDesc: *br.RemoteOutputSignDesc, + KeyRing: keyRing, + CsvDelay: theirDelay, + BreachCsvDelay: fn.Some(theirDelay), + CommitFee: chanState.RemoteCommitment.CommitFee, } if revokedLog != nil { resolveReq.CommitBlob = revokedLog.CustomBlob.ValOpt() @@ -6886,6 +6892,7 @@ func NewUnilateralCloseSummary(chanState *channeldb.OpenChannel, //nolint:funlen // First, we'll generate the commitment point and the revocation point // so we can re-construct the HTLC state and also our payment key. commitType := lntypes.Remote + commitTxHeight := uint32(commitSpend.SpendingHeight) keyRing := DeriveCommitmentKeys( commitPoint, commitType, chanState.ChanType, &chanState.LocalChanCfg, &chanState.RemoteChanCfg, @@ -6920,8 +6927,9 @@ func NewUnilateralCloseSummary(chanState *channeldb.OpenChannel, //nolint:funlen chainfee.SatPerKWeight(remoteCommit.FeePerKw), commitType, signer, remoteCommit.Htlcs, keyRing, &chanState.LocalChanCfg, &chanState.RemoteChanCfg, commitSpend.SpendingTx, - chanState.ChanType, isRemoteInitiator, leaseExpiry, chanState, - auxResult.AuxLeaves, auxResolver, + commitTxHeight, chanState.ChanType, + isRemoteInitiator, leaseExpiry, chanState, auxResult.AuxLeaves, + auxResolver, ) if err != nil { return nil, fmt.Errorf("unable to create htlc resolutions: %w", @@ -7009,21 +7017,24 @@ func NewUnilateralCloseSummary(chanState *channeldb.OpenChannel, //nolint:funlen // At this point, we'll check to see if we need any extra // resolution data for this output. + // + //nolint:ll resolveReq := ResolutionReq{ - ChanPoint: chanState.FundingOutpoint, - ChanType: chanState.ChanType, - ShortChanID: chanState.ShortChanID(), - Initiator: chanState.IsInitiator, - CommitBlob: chanState.RemoteCommitment.CustomBlob, - FundingBlob: chanState.CustomBlob, - Type: input.TaprootRemoteCommitSpend, - CloseType: RemoteForceClose, - CommitTx: commitTxBroadcast, - ContractPoint: *selfPoint, - SignDesc: commitResolution.SelfOutputSignDesc, - KeyRing: keyRing, - CsvDelay: maturityDelay, - CommitFee: chanState.RemoteCommitment.CommitFee, + ChanPoint: chanState.FundingOutpoint, + ChanType: chanState.ChanType, + ShortChanID: chanState.ShortChanID(), + Initiator: chanState.IsInitiator, + CommitBlob: chanState.RemoteCommitment.CustomBlob, + FundingBlob: chanState.CustomBlob, + Type: input.TaprootRemoteCommitSpend, + CloseType: RemoteForceClose, + CommitTx: commitTxBroadcast, + CommitTxBlockHeight: commitTxHeight, + ContractPoint: *selfPoint, + SignDesc: commitResolution.SelfOutputSignDesc, + KeyRing: keyRing, + CsvDelay: maturityDelay, + CommitFee: chanState.RemoteCommitment.CommitFee, } resolveBlob := fn.MapOptionZ( auxResolver, @@ -7209,7 +7220,7 @@ type HtlcResolutions struct { // the remote party's commitment transaction. func newOutgoingHtlcResolution(signer input.Signer, localChanCfg *channeldb.ChannelConfig, commitTx *wire.MsgTx, - htlc *channeldb.HTLC, keyRing *CommitmentKeyRing, + commitTxHeight uint32, htlc *channeldb.HTLC, keyRing *CommitmentKeyRing, feePerKw chainfee.SatPerKWeight, csvDelay, leaseExpiry uint32, whoseCommit lntypes.ChannelParty, isCommitFromInitiator bool, chanType channeldb.ChannelType, chanState *channeldb.OpenChannel, @@ -7285,24 +7296,26 @@ func newOutgoingHtlcResolution(signer input.Signer, } } + //nolint:ll resReq := ResolutionReq{ - ChanPoint: chanState.FundingOutpoint, - ChanType: chanType, - ShortChanID: chanState.ShortChanID(), - Initiator: chanState.IsInitiator, - CommitBlob: chanState.RemoteCommitment.CustomBlob, - FundingBlob: chanState.CustomBlob, - Type: input.TaprootHtlcOfferedRemoteTimeout, - CloseType: RemoteForceClose, - CommitTx: commitTx, - ContractPoint: op, - SignDesc: signDesc, - KeyRing: keyRing, - CsvDelay: htlcCsvDelay, - CltvDelay: fn.Some(htlc.RefundTimeout), - CommitFee: chanState.RemoteCommitment.CommitFee, - HtlcID: fn.Some(htlc.HtlcIndex), - PayHash: fn.Some(htlc.RHash), + ChanPoint: chanState.FundingOutpoint, + ChanType: chanType, + ShortChanID: chanState.ShortChanID(), + Initiator: chanState.IsInitiator, + CommitBlob: chanState.RemoteCommitment.CustomBlob, + FundingBlob: chanState.CustomBlob, + Type: input.TaprootHtlcOfferedRemoteTimeout, + CloseType: RemoteForceClose, + CommitTx: commitTx, + CommitTxBlockHeight: commitTxHeight, + ContractPoint: op, + SignDesc: signDesc, + KeyRing: keyRing, + CsvDelay: htlcCsvDelay, + CltvDelay: fn.Some(htlc.RefundTimeout), + CommitFee: chanState.RemoteCommitment.CommitFee, + HtlcID: fn.Some(htlc.HtlcIndex), + PayHash: fn.Some(htlc.RHash), } resolveRes := fn.MapOptionZ( auxResolver, @@ -7513,31 +7526,33 @@ func newOutgoingHtlcResolution(signer input.Signer, // the sweeping sub-system. resolveRes := fn.MapOptionZ( auxResolver, func(a AuxContractResolver) fn.Result[tlv.Blob] { + //nolint:ll resReq := ResolutionReq{ - ChanPoint: chanState.FundingOutpoint, - ChanType: chanType, - ShortChanID: chanState.ShortChanID(), - Initiator: chanState.IsInitiator, - CommitBlob: chanState.LocalCommitment.CustomBlob, //nolint:ll - FundingBlob: chanState.CustomBlob, - Type: input.TaprootHtlcLocalOfferedTimeout, //nolint:ll - CloseType: LocalForceClose, - CommitTx: commitTx, - ContractPoint: op, - SignDesc: sweepSignDesc, - KeyRing: keyRing, - CsvDelay: htlcCsvDelay, - HtlcAmt: btcutil.Amount(txOut.Value), - CommitCsvDelay: csvDelay, - CltvDelay: fn.Some(htlc.RefundTimeout), - CommitFee: chanState.LocalCommitment.CommitFee, //nolint:ll - HtlcID: fn.Some(htlc.HtlcIndex), - PayHash: fn.Some(htlc.RHash), + ChanPoint: chanState.FundingOutpoint, + ChanType: chanType, + ShortChanID: chanState.ShortChanID(), + Initiator: chanState.IsInitiator, + CommitBlob: chanState.LocalCommitment.CustomBlob, + FundingBlob: chanState.CustomBlob, + Type: input.TaprootHtlcLocalOfferedTimeout, + CloseType: LocalForceClose, + CommitTx: commitTx, + CommitTxBlockHeight: commitTxHeight, + ContractPoint: op, + SignDesc: sweepSignDesc, + KeyRing: keyRing, + CsvDelay: htlcCsvDelay, + HtlcAmt: btcutil.Amount(txOut.Value), + CommitCsvDelay: csvDelay, + CltvDelay: fn.Some(htlc.RefundTimeout), + CommitFee: chanState.LocalCommitment.CommitFee, + HtlcID: fn.Some(htlc.HtlcIndex), + PayHash: fn.Some(htlc.RHash), AuxSigDesc: fn.Some(AuxSigDesc{ SignDetails: *txSignDetails, AuxSig: func() []byte { - tlvType := htlcCustomSigType.TypeVal() //nolint:ll - return htlc.CustomRecords[uint64(tlvType)] //nolint:ll + tlvType := htlcCustomSigType.TypeVal() + return htlc.CustomRecords[uint64(tlvType)] }(), }), } @@ -7573,7 +7588,7 @@ func newOutgoingHtlcResolution(signer input.Signer, // TODO(roasbeef) consolidate code with above func func newIncomingHtlcResolution(signer input.Signer, localChanCfg *channeldb.ChannelConfig, commitTx *wire.MsgTx, - htlc *channeldb.HTLC, keyRing *CommitmentKeyRing, + commitTxHeight uint32, htlc *channeldb.HTLC, keyRing *CommitmentKeyRing, feePerKw chainfee.SatPerKWeight, csvDelay, leaseExpiry uint32, whoseCommit lntypes.ChannelParty, isCommitFromInitiator bool, chanType channeldb.ChannelType, chanState *channeldb.OpenChannel, @@ -7648,26 +7663,28 @@ func newIncomingHtlcResolution(signer input.Signer, } } + //nolint:ll resReq := ResolutionReq{ - ChanPoint: chanState.FundingOutpoint, - ChanType: chanType, - ShortChanID: chanState.ShortChanID(), - Initiator: chanState.IsInitiator, - CommitBlob: chanState.RemoteCommitment.CustomBlob, - Type: input.TaprootHtlcAcceptedRemoteSuccess, - FundingBlob: chanState.CustomBlob, - CloseType: RemoteForceClose, - CommitTx: commitTx, - ContractPoint: op, - SignDesc: signDesc, - KeyRing: keyRing, - HtlcID: fn.Some(htlc.HtlcIndex), - CsvDelay: htlcCsvDelay, - CltvDelay: fn.Some(htlc.RefundTimeout), - CommitFee: chanState.RemoteCommitment.CommitFee, - PayHash: fn.Some(htlc.RHash), - CommitCsvDelay: csvDelay, - HtlcAmt: htlc.Amt.ToSatoshis(), + ChanPoint: chanState.FundingOutpoint, + ChanType: chanType, + ShortChanID: chanState.ShortChanID(), + Initiator: chanState.IsInitiator, + CommitBlob: chanState.RemoteCommitment.CustomBlob, + Type: input.TaprootHtlcAcceptedRemoteSuccess, + FundingBlob: chanState.CustomBlob, + CloseType: RemoteForceClose, + CommitTx: commitTx, + CommitTxBlockHeight: commitTxHeight, + ContractPoint: op, + SignDesc: signDesc, + KeyRing: keyRing, + HtlcID: fn.Some(htlc.HtlcIndex), + CsvDelay: htlcCsvDelay, + CltvDelay: fn.Some(htlc.RefundTimeout), + CommitFee: chanState.RemoteCommitment.CommitFee, + PayHash: fn.Some(htlc.RHash), + CommitCsvDelay: csvDelay, + HtlcAmt: htlc.Amt.ToSatoshis(), } resolveRes := fn.MapOptionZ( auxResolver, @@ -7867,28 +7884,30 @@ func newIncomingHtlcResolution(signer input.Signer, resolveRes := fn.MapOptionZ( auxResolver, func(a AuxContractResolver) fn.Result[tlv.Blob] { + //nolint:ll resReq := ResolutionReq{ - ChanPoint: chanState.FundingOutpoint, - ChanType: chanType, - ShortChanID: chanState.ShortChanID(), - Initiator: chanState.IsInitiator, - CommitBlob: chanState.LocalCommitment.CustomBlob, //nolint:ll - Type: input.TaprootHtlcAcceptedLocalSuccess, //nolint:ll - FundingBlob: chanState.CustomBlob, - CloseType: LocalForceClose, - CommitTx: commitTx, - ContractPoint: op, - SignDesc: sweepSignDesc, - KeyRing: keyRing, - HtlcID: fn.Some(htlc.HtlcIndex), - CsvDelay: htlcCsvDelay, - CommitFee: chanState.LocalCommitment.CommitFee, //nolint:ll - PayHash: fn.Some(htlc.RHash), + ChanPoint: chanState.FundingOutpoint, + ChanType: chanType, + ShortChanID: chanState.ShortChanID(), + Initiator: chanState.IsInitiator, + CommitBlob: chanState.LocalCommitment.CustomBlob, + Type: input.TaprootHtlcAcceptedLocalSuccess, + FundingBlob: chanState.CustomBlob, + CloseType: LocalForceClose, + CommitTx: commitTx, + CommitTxBlockHeight: commitTxHeight, + ContractPoint: op, + SignDesc: sweepSignDesc, + KeyRing: keyRing, + HtlcID: fn.Some(htlc.HtlcIndex), + CsvDelay: htlcCsvDelay, + CommitFee: chanState.LocalCommitment.CommitFee, + PayHash: fn.Some(htlc.RHash), AuxSigDesc: fn.Some(AuxSigDesc{ SignDetails: *txSignDetails, AuxSig: func() []byte { - tlvType := htlcCustomSigType.TypeVal() //nolint:ll - return htlc.CustomRecords[uint64(tlvType)] //nolint:ll + tlvType := htlcCustomSigType.TypeVal() + return htlc.CustomRecords[uint64(tlvType)] }(), }), CommitCsvDelay: csvDelay, @@ -7949,9 +7968,10 @@ func extractHtlcResolutions(feePerKw chainfee.SatPerKWeight, whoseCommit lntypes.ChannelParty, signer input.Signer, htlcs []channeldb.HTLC, keyRing *CommitmentKeyRing, localChanCfg, remoteChanCfg *channeldb.ChannelConfig, - commitTx *wire.MsgTx, chanType channeldb.ChannelType, - isCommitFromInitiator bool, leaseExpiry uint32, - chanState *channeldb.OpenChannel, auxLeaves fn.Option[CommitAuxLeaves], + commitTx *wire.MsgTx, commitTxHeight uint32, + chanType channeldb.ChannelType, isCommitFromInitiator bool, + leaseExpiry uint32, chanState *channeldb.OpenChannel, + auxLeaves fn.Option[CommitAuxLeaves], auxResolver fn.Option[AuxContractResolver]) (*HtlcResolutions, error) { // TODO(roasbeef): don't need to swap csv delay? @@ -7984,8 +8004,8 @@ func extractHtlcResolutions(feePerKw chainfee.SatPerKWeight, // Otherwise, we'll create an incoming HTLC resolution // as we can satisfy the contract. ihr, err := newIncomingHtlcResolution( - signer, localChanCfg, commitTx, &htlc, - keyRing, feePerKw, uint32(csvDelay), + signer, localChanCfg, commitTx, commitTxHeight, + &htlc, keyRing, feePerKw, uint32(csvDelay), leaseExpiry, whoseCommit, isCommitFromInitiator, chanType, chanState, auxLeaves, auxResolver, ) @@ -7999,10 +8019,10 @@ func extractHtlcResolutions(feePerKw chainfee.SatPerKWeight, } ohr, err := newOutgoingHtlcResolution( - signer, localChanCfg, commitTx, &htlc, keyRing, - feePerKw, uint32(csvDelay), leaseExpiry, whoseCommit, - isCommitFromInitiator, chanType, chanState, auxLeaves, - auxResolver, + signer, localChanCfg, commitTx, commitTxHeight, &htlc, + keyRing, feePerKw, uint32(csvDelay), leaseExpiry, + whoseCommit, isCommitFromInitiator, chanType, chanState, + auxLeaves, auxResolver, ) if err != nil { return nil, fmt.Errorf("outgoing resolution "+ @@ -8148,7 +8168,8 @@ func (lc *LightningChannel) ForceClose(opts ...ForceCloseOpt) ( localCommitment := lc.channelState.LocalCommitment summary, err := NewLocalForceCloseSummary( lc.channelState, lc.Signer, commitTx, - localCommitment.CommitHeight, lc.leafStore, lc.auxResolver, + 0, localCommitment.CommitHeight, lc.leafStore, + lc.auxResolver, ) if err != nil { return nil, fmt.Errorf("unable to gen force close "+ @@ -8162,11 +8183,11 @@ func (lc *LightningChannel) ForceClose(opts ...ForceCloseOpt) ( } // NewLocalForceCloseSummary generates a LocalForceCloseSummary from the given -// channel state. The passed commitTx must be a fully signed commitment +// channel state. The passed commitTx must be a fully signed commitment // transaction corresponding to localCommit. func NewLocalForceCloseSummary(chanState *channeldb.OpenChannel, - signer input.Signer, commitTx *wire.MsgTx, stateNum uint64, - leafStore fn.Option[AuxLeafStore], + signer input.Signer, commitTx *wire.MsgTx, commitTxHeight uint32, + stateNum uint64, leafStore fn.Option[AuxLeafStore], auxResolver fn.Option[AuxContractResolver]) (*LocalForceCloseSummary, error) { @@ -8301,20 +8322,21 @@ func NewLocalForceCloseSummary(chanState *channeldb.OpenChannel, func(a AuxContractResolver) fn.Result[tlv.Blob] { //nolint:ll return a.ResolveContract(ResolutionReq{ - ChanPoint: chanState.FundingOutpoint, //nolint:ll - ChanType: chanState.ChanType, - ShortChanID: chanState.ShortChanID(), - Initiator: chanState.IsInitiator, - CommitBlob: chanState.LocalCommitment.CustomBlob, - FundingBlob: chanState.CustomBlob, - Type: input.TaprootLocalCommitSpend, - CloseType: LocalForceClose, - CommitTx: commitTx, - ContractPoint: commitResolution.SelfOutPoint, - SignDesc: commitResolution.SelfOutputSignDesc, - KeyRing: keyRing, - CsvDelay: csvTimeout, - CommitFee: chanState.LocalCommitment.CommitFee, + ChanPoint: chanState.FundingOutpoint, + ChanType: chanState.ChanType, + ShortChanID: chanState.ShortChanID(), + Initiator: chanState.IsInitiator, + CommitBlob: chanState.LocalCommitment.CustomBlob, + FundingBlob: chanState.CustomBlob, + Type: input.TaprootLocalCommitSpend, + CloseType: LocalForceClose, + CommitTx: commitTx, + CommitTxBlockHeight: commitTxHeight, + ContractPoint: commitResolution.SelfOutPoint, + SignDesc: commitResolution.SelfOutputSignDesc, + KeyRing: keyRing, + CsvDelay: csvTimeout, + CommitFee: chanState.LocalCommitment.CommitFee, }) }, ) @@ -8334,9 +8356,9 @@ func NewLocalForceCloseSummary(chanState *channeldb.OpenChannel, htlcResolutions, err := extractHtlcResolutions( chainfee.SatPerKWeight(localCommit.FeePerKw), lntypes.Local, signer, localCommit.Htlcs, keyRing, &chanState.LocalChanCfg, - &chanState.RemoteChanCfg, commitTx, chanState.ChanType, - chanState.IsInitiator, leaseExpiry, chanState, - auxResult.AuxLeaves, auxResolver, + &chanState.RemoteChanCfg, commitTx, commitTxHeight, + chanState.ChanType, chanState.IsInitiator, leaseExpiry, + chanState, auxResult.AuxLeaves, auxResolver, ) if err != nil { return nil, fmt.Errorf("unable to gen htlc resolution: %w", err) From 63da9b325925be48525859016e85875dbcfb4cc7 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Wed, 26 Nov 2025 16:54:56 +0100 Subject: [PATCH 009/134] server: ensure unique addresses for node ann Modifiers of the node announcement may add duplicate addresses, which we remove here after the modifications were applied. This also ensures that any previously added duplicate addresses are removed as well. --- server.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/server.go b/server.go index 6d0b28f7e..b9b6bba3f 100644 --- a/server.go +++ b/server.go @@ -3378,6 +3378,18 @@ func (s *server) genNodeAnnouncement(features *lnwire.RawFeatureVector, modifier(&newNodeAnn) } + // The modifiers may have added duplicate addresses, so we need to + // de-duplicate them here. + uniqueAddrs := map[string]struct{}{} + dedupedAddrs := make([]net.Addr, 0) + for _, addr := range newNodeAnn.Addresses { + if _, ok := uniqueAddrs[addr.String()]; !ok { + uniqueAddrs[addr.String()] = struct{}{} + dedupedAddrs = append(dedupedAddrs, addr) + } + } + newNodeAnn.Addresses = dedupedAddrs + // Sign a new update after applying all of the passed modifiers. err := netann.SignNodeAnnouncement( s.nodeSigner, s.identityKeyLoc, &newNodeAnn, From 7bf9f30b55fe2c34d502e2f6e41c5dd5594a72a5 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Wed, 26 Nov 2025 16:55:10 +0100 Subject: [PATCH 010/134] docs: add release-notes for lnd v0.20.1 --- docs/release-notes/release-notes-0.20.1.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/release-notes-0.20.1.md b/docs/release-notes/release-notes-0.20.1.md index 7dbcd31ea..59370eaae 100644 --- a/docs/release-notes/release-notes-0.20.1.md +++ b/docs/release-notes/release-notes-0.20.1.md @@ -27,7 +27,11 @@ * [Fix a case where resolving the to_local/to_remote output](https://github.com/lightningnetwork/lnd/pull/10387) might take too long. - + +* Fix a bug where [repeated network + addresses](https://github.com/lightningnetwork/lnd/pull/10341) were added to + the node announcement and `getinfo` output. + # New Features ## Functional Enhancements @@ -62,4 +66,5 @@ # Contributors (Alphabetical Order) +* bitromortac * Ziggie From bd8f49afd6532b3e85e836f6c0de9d081c4a756f Mon Sep 17 00:00:00 2001 From: ziggie Date: Thu, 27 Nov 2025 20:01:24 +0100 Subject: [PATCH 011/134] sqldb: add global lock config options for postgres Add two configuration options to control global lock usage for different postgres database backends: - ChannelDBWithGlobalLock: for channeldb access (default: false) - WalletDBWithGlobalLock: for wallet database access (default: true) These allow fine-grained control over which databases use global locks, rather than hardcoding the behavior. This is a temporary measure until the revocation log and wallet are migrated to native SQL and become fully concurrent-safe. --- sqldb/config.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/sqldb/config.go b/sqldb/config.go index 34de293c1..59801dbea 100644 --- a/sqldb/config.go +++ b/sqldb/config.go @@ -44,11 +44,13 @@ func (p *SqliteConfig) Validate() error { // //nolint:ll type PostgresConfig struct { - Dsn string `long:"dsn" description:"Database connection string."` - Timeout time.Duration `long:"timeout" description:"Database connection timeout. Set to zero to disable."` - MaxConnections int `long:"maxconnections" description:"The maximum number of open connections to the database. Set to zero for unlimited."` - SkipMigrations bool `long:"skipmigrations" description:"Skip applying migrations on startup."` - QueryConfig `group:"query" namespace:"query"` + Dsn string `long:"dsn" description:"Database connection string."` + Timeout time.Duration `long:"timeout" description:"Database connection timeout. Set to zero to disable."` + MaxConnections int `long:"maxconnections" description:"The maximum number of open connections to the database. Set to zero for unlimited."` + SkipMigrations bool `long:"skipmigrations" description:"Skip applying migrations on startup."` + ChannelDBWithGlobalLock bool `long:"channeldb-with-global-lock" description:"Use a global lock for channeldb access. This ensures only a single writer at a time but reduces concurrency. This is a temporary workaround until the revocation log is migrated to a native sql schema."` + WalletDBWithGlobalLock bool `long:"walletdb-with-global-lock" description:"Use a global lock for wallet database access. This ensures only a single writer at a time but reduces concurrency. This is a temporary workaround until the wallet subsystem is upgraded to a native sql schema."` + QueryConfig `group:"query" namespace:"query"` } // Validate checks that the PostgresConfig values are valid. From dd304e94fa47352feb596e5364fab33e76b2b984 Mon Sep 17 00:00:00 2001 From: ziggie Date: Fri, 28 Nov 2025 09:34:11 +0100 Subject: [PATCH 012/134] mod: use local path for sqldb until the new version is tagged --- go.mod | 4 ++++ go.sum | 2 -- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 7c501f881..d48da7235 100644 --- a/go.mod +++ b/go.mod @@ -202,6 +202,10 @@ require ( sigs.k8s.io/yaml v1.2.0 // indirect ) +// Use the local sqldb package for development. +// TODO(norbert): remove once sqldb package is tagged. +replace github.com/lightningnetwork/lnd/sqldb => ./sqldb + // This replace is for https://github.com/advisories/GHSA-25xm-hr59-7c27 replace github.com/ulikunitz/xz => github.com/ulikunitz/xz v0.5.11 diff --git a/go.sum b/go.sum index 4c1780dc9..4507c5302 100644 --- a/go.sum +++ b/go.sum @@ -382,8 +382,6 @@ github.com/lightningnetwork/lnd/kvdb v1.4.16 h1:9BZgWdDfjmHRHLS97cz39bVuBAqMc4/p github.com/lightningnetwork/lnd/kvdb v1.4.16/go.mod h1:HW+bvwkxNaopkz3oIgBV6NEnV4jCEZCACFUcNg4xSjM= github.com/lightningnetwork/lnd/queue v1.1.1 h1:99ovBlpM9B0FRCGYJo6RSFDlt8/vOkQQZznVb18iNMI= github.com/lightningnetwork/lnd/queue v1.1.1/go.mod h1:7A6nC1Qrm32FHuhx/mi1cieAiBZo5O6l8IBIoQxvkz4= -github.com/lightningnetwork/lnd/sqldb v1.0.11 h1:X8J3OvdIhJVniQG78Qsp3niErl1zdGMTPvzgiLMWOOo= -github.com/lightningnetwork/lnd/sqldb v1.0.11/go.mod h1:oOdZ7vjmAUmI9He+aFHTunnxKVefHZAfJttZdz16hSg= github.com/lightningnetwork/lnd/ticker v1.1.1 h1:J/b6N2hibFtC7JLV77ULQp++QLtCwT6ijJlbdiZFbSM= github.com/lightningnetwork/lnd/ticker v1.1.1/go.mod h1:waPTRAAcwtu7Ji3+3k+u/xH5GHovTsCoSVpho0KDvdA= github.com/lightningnetwork/lnd/tlv v1.3.2 h1:MO4FCk7F4k5xPMqVZF6Nb/kOpxlwPrUQpYjmyKny5s0= From a25659a6e42e29e077cf9312a0af6cad2e008f4b Mon Sep 17 00:00:00 2001 From: ziggie Date: Thu, 27 Nov 2025 20:03:17 +0100 Subject: [PATCH 013/134] lncfg+scripts: use configurable global lock for postgres backends Replace hardcoded WithGlobalLock assignment with configurable options wallet postgres backends. Also add the WithGlobalLock option to the channeldb table for postgres backends. Defaults: - channeldb: false (allow concurrent access) - wallet: true (maintain safe single-writer behavior) Users can now override these defaults via: - db.postgres.channeldb-with-global-lock - db.postgres.walletdb-with-global-lock This gives operators flexibility while maintaining safe defaults until full native SQL migration is complete. Moreover exclude db.postgres.walletdb-with-global-lock check in the sample config file script. We cannot easily check the correct default because we set it later in the LND startup sequence so we exclude it. --- docs/postgres.md | 10 ++++++++++ lncfg/db.go | 29 ++++++++++++++++++++--------- sample-lnd.conf | 11 +++++++++++ scripts/check-sample-lnd-conf.sh | 2 +- 4 files changed, 42 insertions(+), 10 deletions(-) diff --git a/docs/postgres.md b/docs/postgres.md index 423efc790..89b16ebcf 100644 --- a/docs/postgres.md +++ b/docs/postgres.md @@ -42,6 +42,16 @@ db.postgres.timeout=0 Connection timeout is disabled, to account for situations where the database might be slow for unexpected reasons. +Moreover for particular kv tables we also add the option to access the +tables via a global lock (single wirter). This is a temorpary measure until +these particular tables have a native sql schema. This helps to mitigate +resource exhaustion in case LND experiencing high concurrent load: + +* `db.postgres.walletdb-with-global-lock=true` to run LND with a single writer + for the walletdb_kv table (default is true). +* `db.postgres.channeldb-with-global-lock=false` to run the channeldb_kv table + with a single writer (default is false). + ## Important note about replication In case a replication architecture is planned, streaming replication should be avoided, as the master does not verify the replica is indeed identical, but it will only forward the edits queue, and let the slave catch up autonomously; synchronous mode, albeit slower, is paramount for `lnd` data integrity across the copies, as it will finalize writes only after the slave confirmed successful replication. diff --git a/lncfg/db.go b/lncfg/db.go index 9eb027887..5bd4d2e19 100644 --- a/lncfg/db.go +++ b/lncfg/db.go @@ -115,7 +115,15 @@ func DefaultDB() *DB { }, Postgres: &sqldb.PostgresConfig{ MaxConnections: defaultPostgresMaxConnections, - QueryConfig: *sqldb.DefaultPostgresConfig(), + // Normally we don't use a global lock for channeldb + // access, but if a user encounters huge concurrency + // issues, they can enable this to use a global lock. + ChannelDBWithGlobalLock: false, + // Default to true to maintain safe single-writer + // behavior until the wallet subsystem is upgraded to + // a native sql schema. + WalletDBWithGlobalLock: true, + QueryConfig: *sqldb.DefaultPostgresConfig(), }, Sqlite: &sqldb.SqliteConfig{ MaxConnections: defaultSqliteMaxConnections, @@ -400,9 +408,15 @@ func (db *DB) GetBackends(ctx context.Context, chanDBPath, // users to native SQL. postgresConfig := GetPostgresConfigKVDB(db.Postgres) + // Create a separate config for channeldb with the global lock + // setting if configured. + postgresConfigChannelDB := GetPostgresConfigKVDB(db.Postgres) + postgresConfigChannelDB.WithGlobalLock = db.Postgres. + ChannelDBWithGlobalLock + postgresBackend, err := kvdb.Open( kvdb.PostgresBackendName, ctx, - postgresConfig, NSChannelDB, + postgresConfigChannelDB, NSChannelDB, ) if err != nil { return nil, fmt.Errorf("error opening postgres graph "+ @@ -450,14 +464,11 @@ func (db *DB) GetBackends(ctx context.Context, chanDBPath, } closeFuncs[NSTowerServerDB] = postgresTowerServerBackend.Close - // The wallet subsystem is still not robust enough to run it - // without a single writer in postgres therefore we create a - // new config with the global lock enabled. - // - // NOTE: This is a temporary measure and should be removed as - // soon as the wallet code is more robust. + // Create a separate config for wallet with the global lock + // setting if configured. postgresConfigWalletDB := GetPostgresConfigKVDB(db.Postgres) - postgresConfigWalletDB.WithGlobalLock = true + postgresConfigWalletDB.WithGlobalLock = db.Postgres. + WalletDBWithGlobalLock postgresWalletBackend, err := kvdb.Open( kvdb.PostgresBackendName, ctx, diff --git a/sample-lnd.conf b/sample-lnd.conf index c3b3a96b1..f20035c86 100644 --- a/sample-lnd.conf +++ b/sample-lnd.conf @@ -1616,6 +1616,17 @@ ; Whether to skip executing schema migrations. ; db.postgres.skipmigrations=false +; Use a global lock for channeldb access. This ensures only a single writer at +; a time but reduces concurrency. This is a temporary workaround until the +; revocation log is migrated to native SQL. +; db.postgres.channeldb-with-global-lock=false + + +; Use a global lock for wallet database access. This is a temporary workaround +; until the wallet subsystem is upgraded to a native sql schema. +; db.postgres.walletdb-with-global-lock=true + + ; The maximum number of elements to use in a native-SQL batch query IN clause. ; db.postgres.query.max-batch-size=5000 diff --git a/scripts/check-sample-lnd-conf.sh b/scripts/check-sample-lnd-conf.sh index 48cbad7f6..0f51e47f0 100755 --- a/scripts/check-sample-lnd-conf.sh +++ b/scripts/check-sample-lnd-conf.sh @@ -59,7 +59,7 @@ OPTIONS_NO_LND_DEFAULT_VALUE_CHECK="channel-max-fee-exposure adminmacaroonpath \ backupfilepath maxchansize bitcoin.chaindir bitcoin.defaultchanconfs \ bitcoin.defaultremotedelay bitcoin.dnsseed signrpc.signermacaroonpath \ walletrpc.walletkitmacaroonpath chainrpc.notifiermacaroonpath \ - routerrpc.routermacaroonpath" + routerrpc.routermacaroonpath db.postgres.walletdb-with-global-lock" # EXITCODE is returned at the end after all checks are performed and set to 1 From 6bdfb1dc4dd4b29a4a8ab946b044672ab0e5bc3e Mon Sep 17 00:00:00 2001 From: ziggie Date: Thu, 27 Nov 2025 20:15:24 +0100 Subject: [PATCH 014/134] docs: add release-notes for LND 20.1 --- docs/release-notes/release-notes-0.20.1.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/release-notes/release-notes-0.20.1.md b/docs/release-notes/release-notes-0.20.1.md index 59370eaae..4cc4556fb 100644 --- a/docs/release-notes/release-notes-0.20.1.md +++ b/docs/release-notes/release-notes-0.20.1.md @@ -51,6 +51,15 @@ ## Performance Improvements +* [Added new Postgres configuration + options](https://github.com/lightningnetwork/lnd/pull/10394) + `db.postgres.channeldb-with-global-lock` and + `db.postgres.walletdb-with-global-lock` to allow fine-grained control over + database concurrency. The channeldb global lock defaults to `false` to enable + concurrent access, while the wallet global lock defaults to `true` to maintain + safe single-writer behavior until the wallet subsystem is fully + concurrent-safe. + ## Deprecations # Technical and Architectural Updates From 2d477d699d0c26f561e201c7cd2cb46db2b7d708 Mon Sep 17 00:00:00 2001 From: ziggie Date: Wed, 19 Nov 2025 22:08:31 +0100 Subject: [PATCH 015/134] routing: allow misson control manager to startup despite errors We now allow the mission control manager to skip over deserializable errors. We cannot repair this these results but we just skip over it so we can startup properly. When fetchAll() encounters entries that fail to deserialize, in addition to skipping them, now also: - Delete the corrupted entries from the database - Remove them from the in-memory keysMap and keys tracking structures This prevents corrupted entries from: - Being counted toward maxRecords, which would cause valid entries to be pruned prematurely - Persisting in the database indefinitely - Causing inaccurate entry counts in startup logs --- routing/missioncontrol_store.go | 74 ++++++++++++++++++- routing/missioncontrol_store_test.go | 105 +++++++++++++++++++++++++++ 2 files changed, 177 insertions(+), 2 deletions(-) diff --git a/routing/missioncontrol_store.go b/routing/missioncontrol_store.go index 7398ca0dc..373bc3003 100644 --- a/routing/missioncontrol_store.go +++ b/routing/missioncontrol_store.go @@ -133,30 +133,100 @@ func (b *missionControlStore) clear() error { } // fetchAll returns all results currently stored in the database. +// It also removes any corrupted entries that fail to deserialize from both +// the database and the in-memory tracking structures. func (b *missionControlStore) fetchAll() ([]*paymentResult, error) { var results []*paymentResult + var corruptedKeys [][]byte + // Read all results and identify corrupted entries. err := b.db.view(func(resultBucket kvdb.RBucket) error { results = make([]*paymentResult, 0) + corruptedKeys = make([][]byte, 0) - return resultBucket.ForEach(func(k, v []byte) error { + err := resultBucket.ForEach(func(k, v []byte) error { result, err := deserializeResult(k, v) + + // In case of an error, track the key for removal. if err != nil { - return err + log.Warnf("Failed to deserialize mission "+ + "control entry (key=%x): %v", k, err) + + // Make a copy of the key since ForEach reuses + // the slice. + keyCopy := make([]byte, len(k)) + copy(keyCopy, k) + corruptedKeys = append(corruptedKeys, keyCopy) + + return nil } results = append(results, result) return nil }) + if err != nil { + return err + } + return nil }, func() { results = nil + corruptedKeys = nil }) if err != nil { return nil, err } + // Delete corrupted entries from the database which were identified + // when loading the results from the database. + // + // TODO: This code part should eventually be removed once we move the + // mission control store to a native sql database and have to do a + // full migration of the data. + if len(corruptedKeys) > 0 { + err = b.db.update(func(resultBucket kvdb.RwBucket) error { + for _, key := range corruptedKeys { + if err := resultBucket.Delete(key); err != nil { + return fmt.Errorf("failed to delete "+ + "corrupted entry: %w", err) + } + } + + return nil + }, func() {}) + if err != nil { + return nil, err + } + + // Build a set of corrupted keys. + corruptedSet := make(map[string]struct{}, len(corruptedKeys)) + for _, key := range corruptedKeys { + corruptedSet[string(key)] = struct{}{} + } + + // Remove corrupted keys from in-memory map. + for keyStr := range corruptedSet { + delete(b.keysMap, keyStr) + } + + // Remove from the keys list in a single pass. + for e := b.keys.Front(); e != nil; { + next := e.Next() + keyVal, ok := e.Value.(string) + if ok { + _, isCorrupted := corruptedSet[keyVal] + if isCorrupted { + b.keys.Remove(e) + } + } + e = next + } + + log.Infof("Removed %d corrupted mission control entries", + len(corruptedKeys)) + } + return results, nil } diff --git a/routing/missioncontrol_store_test.go b/routing/missioncontrol_store_test.go index b020fcbb4..889dca071 100644 --- a/routing/missioncontrol_store_test.go +++ b/routing/missioncontrol_store_test.go @@ -332,3 +332,108 @@ func BenchmarkMissionControlStoreFlushing(b *testing.B) { }) } } + +// TestMissionControlStoreDeletesCorruptedEntries tests that fetchAll() skips +// entries that fail to deserialize, deletes them from the database, and +// removes them from the in-memory tracking structures. +func TestMissionControlStoreDeletesCorruptedEntries(t *testing.T) { + h := newMCStoreTestHarness(t, testMaxRecords, time.Second) + store := h.store + + failureSourceIdx := 1 + + // Create two valid results. + result1 := newPaymentResult( + 1, mcStoreTestRoute, testTime, testTime, + fn.Some(newPaymentFailure( + &failureSourceIdx, + lnwire.NewFailIncorrectDetails(100, 1000), + )), + ) + + result2 := newPaymentResult( + 2, mcStoreTestRoute, testTime.Add(time.Hour), + testTime.Add(time.Hour), + fn.Some(newPaymentFailure( + &failureSourceIdx, + lnwire.NewFailIncorrectDetails(100, 1000), + )), + ) + + // Store both results. + store.AddResult(result1) + store.AddResult(result2) + require.NoError(t, store.storeResults()) + + // Insert a corrupted entry into the database. + var corruptedKey [8 + 8 + 33]byte + byteOrder.PutUint64(corruptedKey[:], uint64(testTime.Add( + 30*time.Minute).UnixNano()), + ) + byteOrder.PutUint64(corruptedKey[8:], 99) // Unique ID. + copy(corruptedKey[16:], result1.route.Val.sourcePubKey.Val[:]) + + err := store.db.update(func(bucket kvdb.RwBucket) error { + // Insert corrupted/invalid TLV data that will fail to + // deserialize. + corruptedValue := []byte{0xFF, 0xFF, 0xFF, 0xFF} + + return bucket.Put(corruptedKey[:], corruptedValue) + }, func() {}) + require.NoError(t, err) + + // Add the corrupted key to in-memory tracking to simulate it being + // loaded at startup (newMissionControlStore populates keysMap from + // all DB keys). + corruptedKeyStr := string(corruptedKey[:]) + store.keysMap[corruptedKeyStr] = struct{}{} + store.keys.PushBack(corruptedKeyStr) + + // Verify the corrupted key is in the in-memory tracking. + _, exists := store.keysMap[corruptedKeyStr] + require.True(t, exists, "corrupted key should be in keysMap") + + // Verify we have 3 entries in the database before fetchAll. + var dbEntryCountBefore int + err = store.db.view(func(bucket kvdb.RBucket) error { + return bucket.ForEach(func(k, v []byte) error { + dbEntryCountBefore++ + return nil + }) + }, func() { + dbEntryCountBefore = 0 + }) + require.NoError(t, err) + require.Equal(t, 3, dbEntryCountBefore, "should have 3 entries "+ + "in the database before cleanup") + + // Now fetch all results. The corrupted entry should be skipped, + // deleted from the DB, and removed from in-memory tracking. + results, err := store.fetchAll() + require.NoError(t, err, "fetchAll should not return an error "+ + "even when encountering corrupted entries") + require.Len(t, results, 2, "should skip the corrupted entry and "+ + "return only valid results") + + // Verify we still have the correct results. + require.Equal(t, result1, results[0]) + require.Equal(t, result2, results[1]) + + // Verify the corrupted entry was removed from in-memory tracking. + _, exists = store.keysMap[corruptedKeyStr] + require.False(t, exists, "corrupted key should not exist in keysMap") + + // Verify the corrupted entry was deleted from the database. + var dbEntryCountAfter int + err = store.db.view(func(bucket kvdb.RBucket) error { + return bucket.ForEach(func(k, v []byte) error { + dbEntryCountAfter++ + return nil + }) + }, func() { + dbEntryCountAfter = 0 + }) + require.NoError(t, err) + require.Equal(t, 2, dbEntryCountAfter, "corrupted entry should be "+ + "deleted from the database") +} From 1fa6f70b0d47416b3916309165f52c5ab04b605e Mon Sep 17 00:00:00 2001 From: ziggie Date: Sat, 22 Nov 2025 02:13:08 +0100 Subject: [PATCH 016/134] docs: add release-notes for LND 20.1 --- docs/release-notes/release-notes-0.20.1.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/release-notes/release-notes-0.20.1.md b/docs/release-notes/release-notes-0.20.1.md index 59370eaae..19dff4bcc 100644 --- a/docs/release-notes/release-notes-0.20.1.md +++ b/docs/release-notes/release-notes-0.20.1.md @@ -31,6 +31,11 @@ * Fix a bug where [repeated network addresses](https://github.com/lightningnetwork/lnd/pull/10341) were added to the node announcement and `getinfo` output. + +* [Fix a startup issue in LND when encountering a + deserialization issue](https://github.com/lightningnetwork/lnd/pull/10383) + in the mission control store. Now we skip over potential errors and also + delete them from the store. # New Features From 13c6a3777cb20e7b7c22a6fc5b5a2fae02b472d3 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Fri, 14 Nov 2025 10:45:58 +0200 Subject: [PATCH 017/134] graph/db: add test for SetSourceNode same timestamp behavior This commit adds TestSetSourceNodeSameTimestamp to demonstrate the current behavior when SetSourceNode is called with the same last update timestamp. The test reveals a difference between the SQL and bbolt implementations: - SQL store returns sql.ErrNoRows when attempting to update with the same timestamp, as the upsert query's UPDATE clause requires the new timestamp to be strictly greater than the existing one - bbolt store silently ignores stale updates and returns no error This behavior is important to document because our own node announcements may change quickly with the same timestamp, unlike announcements from other nodes where same timestamp typically means identical parameters. --- graph/db/graph_test.go | 57 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/graph/db/graph_test.go b/graph/db/graph_test.go index ee5cf8dbf..a37bcc224 100644 --- a/graph/db/graph_test.go +++ b/graph/db/graph_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "crypto/sha256" + "database/sql" "encoding/hex" "errors" "fmt" @@ -406,6 +407,62 @@ func TestSourceNode(t *testing.T) { compareNodes(t, testNode, sourceNode) } +// TestSetSourceNodeSameTimestamp demonstrates that SetSourceNode can return an +// error when called with the same last update timestamp. Calling SetSourceNode +// with the same timestamp should be allowed (unlike AddNode), as it is +// possible that our own node announcement may change quickly. This will be +// fixed in an upcoming commit. +func TestSetSourceNodeSameTimestamp(t *testing.T) { + t.Parallel() + ctx := t.Context() + + graph := MakeTestGraph(t) + + _, isSQLStore := graph.V1Store.(*SQLStore) + + // Create and set the initial source node. + testNode := createTestVertex(t) + require.NoError(t, graph.SetSourceNode(ctx, testNode)) + + // Verify the source node was set correctly. + sourceNode, err := graph.SourceNode(ctx) + require.NoError(t, err) + compareNodes(t, testNode, sourceNode) + + // Create a modified version of the node with the same timestamp but + // different parameters (e.g., different alias and color). This + // could well be the case for our own node announcement (unlike other + // announcements where same timestamp means same parameters). + modifiedNode := &models.Node{ + PubKeyBytes: testNode.PubKeyBytes, + HaveNodeAnnouncement: true, + // Same timestamp. + LastUpdate: testNode.LastUpdate, + // Different alias. + Alias: "different-alias", + Color: color.RGBA{R: 100, G: 200, B: 50, A: 0}, + Addresses: testNode.Addresses, + Features: testNode.Features, + AuthSigBytes: testNode.AuthSigBytes, + } + + // Attempt to set the source node with the same timestamp but + // different parameters. + err = graph.SetSourceNode(ctx, modifiedNode) + + // The SQL store will return sql.ErrNoRows because the UPDATE clause + // in the upsert query requires the new timestamp to be strictly + // greater than the existing one. When this condition is not met, no + // rows are updated and the SQL query returns ErrNoRows. The bbolt KV + // store, on the other hand, silently ignores stale updates and returns + // no error. + if isSQLStore { + require.ErrorIs(t, err, sql.ErrNoRows) + } else { + require.NoError(t, err) + } +} + // TestEdgeInsertionDeletion tests the basic CRUD operations for channel edges. func TestEdgeInsertionDeletion(t *testing.T) { t.Parallel() From 2a9e82b60ae465c09722ed2e50b225bad4616124 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 26 Nov 2025 07:58:08 +0200 Subject: [PATCH 018/134] sqldb: add UpsertSelfNode query This query is less strict in terms of the latest update timestamp field. We want to be less strict with our own node data since we always want our own updates recorded. --- sqldb/sqlc/graph.sql.go | 45 ++++++++++++++++++++++++++++++++++++ sqldb/sqlc/querier.go | 4 ++++ sqldb/sqlc/queries/graph.sql | 21 +++++++++++++++++ 3 files changed, 70 insertions(+) diff --git a/sqldb/sqlc/graph.sql.go b/sqldb/sqlc/graph.sql.go index 9c2702737..8ed9333d7 100644 --- a/sqldb/sqlc/graph.sql.go +++ b/sqldb/sqlc/graph.sql.go @@ -3735,6 +3735,51 @@ func (q *Queries) UpsertPruneLogEntry(ctx context.Context, arg UpsertPruneLogEnt return err } +const upsertSourceNode = `-- name: UpsertSourceNode :one +INSERT INTO graph_nodes ( + version, pub_key, alias, last_update, color, signature +) VALUES ( + $1, $2, $3, $4, $5, $6 +) +ON CONFLICT (pub_key, version) + -- Update the following fields if a conflict occurs on pub_key + -- and version. + DO UPDATE SET + alias = EXCLUDED.alias, + last_update = EXCLUDED.last_update, + color = EXCLUDED.color, + signature = EXCLUDED.signature +WHERE graph_nodes.last_update IS NULL + OR EXCLUDED.last_update >= graph_nodes.last_update +RETURNING id +` + +type UpsertSourceNodeParams struct { + Version int16 + PubKey []byte + Alias sql.NullString + LastUpdate sql.NullInt64 + Color sql.NullString + Signature []byte +} + +// We use a separate upsert for our own node since we want to be less strict +// about the last_update field. For our own node, we always want to +// update the record even if the last_update is the same as what we have. +func (q *Queries) UpsertSourceNode(ctx context.Context, arg UpsertSourceNodeParams) (int64, error) { + row := q.db.QueryRowContext(ctx, upsertSourceNode, + arg.Version, + arg.PubKey, + arg.Alias, + arg.LastUpdate, + arg.Color, + arg.Signature, + ) + var id int64 + err := row.Scan(&id) + return id, err +} + const upsertZombieChannel = `-- name: UpsertZombieChannel :exec /* ───────────────────────────────────────────── graph_zombie_channels table queries diff --git a/sqldb/sqlc/querier.go b/sqldb/sqlc/querier.go index 0087559be..7b7b06495 100644 --- a/sqldb/sqlc/querier.go +++ b/sqldb/sqlc/querier.go @@ -147,6 +147,10 @@ type Querier interface { UpsertNodeAddress(ctx context.Context, arg UpsertNodeAddressParams) error UpsertNodeExtraType(ctx context.Context, arg UpsertNodeExtraTypeParams) error UpsertPruneLogEntry(ctx context.Context, arg UpsertPruneLogEntryParams) error + // We use a separate upsert for our own node since we want to be less strict + // about the last_update field. For our own node, we always want to + // update the record even if the last_update is the same as what we have. + UpsertSourceNode(ctx context.Context, arg UpsertSourceNodeParams) (int64, error) UpsertZombieChannel(ctx context.Context, arg UpsertZombieChannelParams) error } diff --git a/sqldb/sqlc/queries/graph.sql b/sqldb/sqlc/queries/graph.sql index 19087fc1b..b9bee1822 100644 --- a/sqldb/sqlc/queries/graph.sql +++ b/sqldb/sqlc/queries/graph.sql @@ -21,6 +21,27 @@ WHERE graph_nodes.last_update IS NULL OR EXCLUDED.last_update > graph_nodes.last_update RETURNING id; +-- We use a separate upsert for our own node since we want to be less strict +-- about the last_update field. For our own node, we always want to +-- update the record even if the last_update is the same as what we have. +-- name: UpsertSourceNode :one +INSERT INTO graph_nodes ( + version, pub_key, alias, last_update, color, signature +) VALUES ( + $1, $2, $3, $4, $5, $6 +) +ON CONFLICT (pub_key, version) + -- Update the following fields if a conflict occurs on pub_key + -- and version. + DO UPDATE SET + alias = EXCLUDED.alias, + last_update = EXCLUDED.last_update, + color = EXCLUDED.color, + signature = EXCLUDED.signature +WHERE graph_nodes.last_update IS NULL + OR EXCLUDED.last_update >= graph_nodes.last_update +RETURNING id; + -- name: GetNodesByIDs :many SELECT * FROM graph_nodes From c207461aca909325057bed2b9a4eb61f2ffeb80d Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Fri, 14 Nov 2025 11:23:31 +0200 Subject: [PATCH 019/134] graph/db: fix SetSourceNode race with lenient upsert This commit fixes a race condition where multiple goroutines call SetSourceNode concurrently during startup, causing sql.ErrNoRows errors. The race occurs when multiple code paths (setSelfNode, createNewHiddenService, RPC updates) read the same old timestamp, independently increment it to the same new value (T+1), and race to write. The fix uses the new UpsertSourceNode SQL query (without strict timestamp constraint) instead of UpsertNode. This allows last-write-wins semantics for our own node, ensuring all parameter changes persist even when timestamps collide. Refactored sql_store.go for reusability: - upsertNodeAncillaryData: common logic for features/addresses/extras - populateNodeParams: common parameter building with callback pattern - buildNodeUpsertParams: builds params for strict UpsertNode - buildSourceNodeUpsertParams: builds params for lenient UpsertSourceNode - upsertSourceNode: new function using lenient query Updated TestSetSourceNodeSameTimestamp to verify that concurrent updates with the same timestamp now succeed and parameter changes persist. Fixes the itest error: "unable to upsert source node: upserting node(...): sql: no rows in result set" --- graph/db/graph_test.go | 46 +++++------ graph/db/sql_store.go | 177 +++++++++++++++++++++++++++++++++-------- 2 files changed, 165 insertions(+), 58 deletions(-) diff --git a/graph/db/graph_test.go b/graph/db/graph_test.go index a37bcc224..41954bc2d 100644 --- a/graph/db/graph_test.go +++ b/graph/db/graph_test.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "crypto/sha256" - "database/sql" "encoding/hex" "errors" "fmt" @@ -407,19 +406,19 @@ func TestSourceNode(t *testing.T) { compareNodes(t, testNode, sourceNode) } -// TestSetSourceNodeSameTimestamp demonstrates that SetSourceNode can return an -// error when called with the same last update timestamp. Calling SetSourceNode -// with the same timestamp should be allowed (unlike AddNode), as it is -// possible that our own node announcement may change quickly. This will be -// fixed in an upcoming commit. +// TestSetSourceNodeSameTimestamp tests that SetSourceNode accepts updates +// with the same timestamp. This is necessary because multiple code paths +// (setSelfNode, createNewHiddenService, RPC updates) can race during startup, +// reading the same old timestamp and independently incrementing it to the same +// new value. For our own node, we want parameter changes to persist even with +// timestamp collisions (unlike network gossip where same timestamp means same +// content). func TestSetSourceNodeSameTimestamp(t *testing.T) { t.Parallel() ctx := t.Context() graph := MakeTestGraph(t) - _, isSQLStore := graph.V1Store.(*SQLStore) - // Create and set the initial source node. testNode := createTestVertex(t) require.NoError(t, graph.SetSourceNode(ctx, testNode)) @@ -431,8 +430,9 @@ func TestSetSourceNodeSameTimestamp(t *testing.T) { // Create a modified version of the node with the same timestamp but // different parameters (e.g., different alias and color). This - // could well be the case for our own node announcement (unlike other - // announcements where same timestamp means same parameters). + // simulates the race condition where multiple goroutines read the + // same old timestamp, independently increment it, and try to update + // with different changes. modifiedNode := &models.Node{ PubKeyBytes: testNode.PubKeyBytes, HaveNodeAnnouncement: true, @@ -447,20 +447,20 @@ func TestSetSourceNodeSameTimestamp(t *testing.T) { } // Attempt to set the source node with the same timestamp but - // different parameters. - err = graph.SetSourceNode(ctx, modifiedNode) + // different parameters. This should now succeed for both SQL and KV + // stores. The SQL store uses UpsertSourceNode which removes the + // strict timestamp constraint, allowing last-write-wins semantics. + require.NoError(t, graph.SetSourceNode(ctx, modifiedNode)) - // The SQL store will return sql.ErrNoRows because the UPDATE clause - // in the upsert query requires the new timestamp to be strictly - // greater than the existing one. When this condition is not met, no - // rows are updated and the SQL query returns ErrNoRows. The bbolt KV - // store, on the other hand, silently ignores stale updates and returns - // no error. - if isSQLStore { - require.ErrorIs(t, err, sql.ErrNoRows) - } else { - require.NoError(t, err) - } + // Verify that the parameter changes actually persisted. + updatedNode, err := graph.SourceNode(ctx) + require.NoError(t, err) + require.Equal(t, "different-alias", updatedNode.Alias) + require.Equal( + t, color.RGBA{R: 100, G: 200, B: 50, A: 0}, + updatedNode.Color, + ) + require.Equal(t, testNode.LastUpdate, updatedNode.LastUpdate) } // TestEdgeInsertionDeletion tests the basic CRUD operations for channel edges. diff --git a/graph/db/sql_store.go b/graph/db/sql_store.go index f67894e4c..155515c2e 100644 --- a/graph/db/sql_store.go +++ b/graph/db/sql_store.go @@ -55,6 +55,7 @@ type SQLQueries interface { Node queries. */ UpsertNode(ctx context.Context, arg sqlc.UpsertNodeParams) (int64, error) + UpsertSourceNode(ctx context.Context, arg sqlc.UpsertSourceNodeParams) (int64, error) GetNodeByPubKey(ctx context.Context, arg sqlc.GetNodeByPubKeyParams) (sqlc.GraphNode, error) GetNodesByIDs(ctx context.Context, ids []int64) ([]sqlc.GraphNode, error) GetNodeIDByPubKey(ctx context.Context, arg sqlc.GetNodeIDByPubKeyParams) (int64, error) @@ -532,7 +533,14 @@ func (s *SQLStore) SetSourceNode(ctx context.Context, node *models.Node) error { return s.db.ExecTx(ctx, sqldb.WriteTxOpt(), func(db SQLQueries) error { - id, err := upsertNode(ctx, db, node) + // For the source node, we use a less strict upsert that allows + // updates even when the timestamp hasn't changed. This handles + // the race condition where multiple goroutines (e.g., + // setSelfNode, createNewHiddenService, RPC updates) read the + // same old timestamp, independently increment it, and try to + // write concurrently. We want all parameter changes to persist, + // even if timestamps collide. + id, err := upsertSourceNode(ctx, db, node) if err != nil { return fmt.Errorf("unable to upsert source node: %w", err) @@ -3602,6 +3610,135 @@ func getNodeFeatures(ctx context.Context, db SQLQueries, return features, nil } +// upsertNodeAncillaryData updates the node's features, addresses, and extra +// signed fields. This is common logic shared by upsertNode and +// upsertSourceNode. +func upsertNodeAncillaryData(ctx context.Context, db SQLQueries, + nodeID int64, node *models.Node) error { + + // Update the node's features. + err := upsertNodeFeatures(ctx, db, nodeID, node.Features) + if err != nil { + return fmt.Errorf("inserting node features: %w", err) + } + + // Update the node's addresses. + err = upsertNodeAddresses(ctx, db, nodeID, node.Addresses) + if err != nil { + return fmt.Errorf("inserting node addresses: %w", err) + } + + // Convert the flat extra opaque data into a map of TLV types to + // values. + extra, err := marshalExtraOpaqueData(node.ExtraOpaqueData) + if err != nil { + return fmt.Errorf("unable to marshal extra opaque data: %w", + err) + } + + // Update the node's extra signed fields. + err = upsertNodeExtraSignedFields(ctx, db, nodeID, extra) + if err != nil { + return fmt.Errorf("inserting node extra TLVs: %w", err) + } + + return nil +} + +// populateNodeParams populates the common node parameters from a models.Node. +// This is a helper for building UpsertNodeParams and UpsertSourceNodeParams. +func populateNodeParams(node *models.Node, + setParams func(lastUpdate sql.NullInt64, alias, + colorStr sql.NullString, signature []byte)) { + + if !node.HaveNodeAnnouncement { + return + } + + lastUpdate := sqldb.SQLInt64(node.LastUpdate.Unix()) + alias := sqldb.SQLStrValid(node.Alias) + colorStr := sqldb.SQLStrValid(EncodeHexColor(node.Color)) + + setParams(lastUpdate, alias, colorStr, node.AuthSigBytes) +} + +// buildNodeUpsertParams builds the parameters for upserting a node using the +// strict UpsertNode query (requires timestamp to be increasing). +func buildNodeUpsertParams(node *models.Node) sqlc.UpsertNodeParams { + params := sqlc.UpsertNodeParams{ + Version: int16(ProtocolV1), + PubKey: node.PubKeyBytes[:], + } + + populateNodeParams( + node, func(lastUpdate sql.NullInt64, alias, + colorStr sql.NullString, + signature []byte) { + + params.LastUpdate = lastUpdate + params.Alias = alias + params.Color = colorStr + params.Signature = signature + }, + ) + + return params +} + +// buildSourceNodeUpsertParams builds the parameters for upserting the source +// node using the lenient UpsertSourceNode query (allows same timestamp). +func buildSourceNodeUpsertParams( + node *models.Node) sqlc.UpsertSourceNodeParams { + + params := sqlc.UpsertSourceNodeParams{ + Version: int16(ProtocolV1), + PubKey: node.PubKeyBytes[:], + } + + populateNodeParams( + node, func(lastUpdate sql.NullInt64, alias, + colorStr sql.NullString, signature []byte) { + + params.LastUpdate = lastUpdate + params.Alias = alias + params.Color = colorStr + params.Signature = signature + }, + ) + + return params +} + +// upsertSourceNode upserts the source node record into the database using a +// less strict upsert that allows updates even when the timestamp hasn't +// changed. This is necessary to handle concurrent updates to our own node +// during startup and runtime. The node's features, addresses and extra TLV +// types are also updated. The node's DB ID is returned. +func upsertSourceNode(ctx context.Context, db SQLQueries, + node *models.Node) (int64, error) { + + params := buildSourceNodeUpsertParams(node) + + nodeID, err := db.UpsertSourceNode(ctx, params) + if err != nil { + return 0, fmt.Errorf("upserting source node(%x): %w", + node.PubKeyBytes, err) + } + + // We can exit here if we don't have the announcement yet. + if !node.HaveNodeAnnouncement { + return nodeID, nil + } + + // Update the ancillary node data (features, addresses, extra fields). + err = upsertNodeAncillaryData(ctx, db, nodeID, node) + if err != nil { + return 0, err + } + + return nodeID, nil +} + // upsertNode upserts the node record into the database. If the node already // exists, then the node's information is updated. If the node doesn't exist, // then a new node is created. The node's features, addresses and extra TLV @@ -3609,17 +3746,7 @@ func getNodeFeatures(ctx context.Context, db SQLQueries, func upsertNode(ctx context.Context, db SQLQueries, node *models.Node) (int64, error) { - params := sqlc.UpsertNodeParams{ - Version: int16(ProtocolV1), - PubKey: node.PubKeyBytes[:], - } - - if node.HaveNodeAnnouncement { - params.LastUpdate = sqldb.SQLInt64(node.LastUpdate.Unix()) - params.Color = sqldb.SQLStrValid(EncodeHexColor(node.Color)) - params.Alias = sqldb.SQLStrValid(node.Alias) - params.Signature = node.AuthSigBytes - } + params := buildNodeUpsertParams(node) nodeID, err := db.UpsertNode(ctx, params) if err != nil { @@ -3632,30 +3759,10 @@ func upsertNode(ctx context.Context, db SQLQueries, return nodeID, nil } - // Update the node's features. - err = upsertNodeFeatures(ctx, db, nodeID, node.Features) + // Update the ancillary node data (features, addresses, extra fields). + err = upsertNodeAncillaryData(ctx, db, nodeID, node) if err != nil { - return 0, fmt.Errorf("inserting node features: %w", err) - } - - // Update the node's addresses. - err = upsertNodeAddresses(ctx, db, nodeID, node.Addresses) - if err != nil { - return 0, fmt.Errorf("inserting node addresses: %w", err) - } - - // Convert the flat extra opaque data into a map of TLV types to - // values. - extra, err := marshalExtraOpaqueData(node.ExtraOpaqueData) - if err != nil { - return 0, fmt.Errorf("unable to marshal extra opaque data: %w", - err) - } - - // Update the node's extra signed fields. - err = upsertNodeExtraSignedFields(ctx, db, nodeID, extra) - if err != nil { - return 0, fmt.Errorf("inserting node extra TLVs: %w", err) + return 0, err } return nodeID, nil From 2590593c3f7d3b7524e22a21f4f92d4e4e252abd Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 26 Nov 2025 10:03:11 +0200 Subject: [PATCH 020/134] docs: add release note --- docs/release-notes/release-notes-0.20.1.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/release-notes/release-notes-0.20.1.md b/docs/release-notes/release-notes-0.20.1.md index 844ae3089..f1e7023bf 100644 --- a/docs/release-notes/release-notes-0.20.1.md +++ b/docs/release-notes/release-notes-0.20.1.md @@ -31,6 +31,11 @@ * Fix a bug where [repeated network addresses](https://github.com/lightningnetwork/lnd/pull/10341) were added to the node announcement and `getinfo` output. + +* [Fix source node race + condition](https://github.com/lightningnetwork/lnd/pull/10371) which could + prevent a node from starting up if two goroutines attempt to update the + node's announcement at the same time. * [Fix a startup issue in LND when encountering a deserialization issue](https://github.com/lightningnetwork/lnd/pull/10383) From 24de03d766abd09756ce917eadf1766812010948 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Thu, 27 Nov 2025 13:15:30 +0000 Subject: [PATCH 021/134] tls_manager_test.go: reproduce partial tls files handling When there is only one of the tls pairs (key/certificate) and the other is missing, the TLS manager currently assumes it exists and ignore generating them. This results in error propgated to user that the other tls pair file is missing/not found. --- tls_manager_test.go | 89 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/tls_manager_test.go b/tls_manager_test.go index 42f010411..541b123c4 100644 --- a/tls_manager_test.go +++ b/tls_manager_test.go @@ -369,3 +369,92 @@ func newTestDirectory(t *testing.T) (string, string, string) { return tempDir, certPath, keyPath } + +// TestGenerateCertPairWithPartialFiles tests that generateCertPair regenerates +// a cert/key pair when only one file exists. +func TestGenerateCertPairWithPartialFiles(t *testing.T) { + t.Parallel() + + keyRing := &mock.SecretKeyRing{ + RootKey: privKey, + } + + testCases := []struct { + name string + setup func(t *testing.T, certPath, keyPath string) + }{ + { + name: "only key exists", + setup: func(t *testing.T, certPath, keyPath string) { + // Create only a key file. It simulates leftover + // from previous run. + _, keyBytes := genCertPair(t, false) + keyBuf := &bytes.Buffer{} + err := pem.Encode( + keyBuf, &pem.Block{ + Type: "EC PRIVATE KEY", + Bytes: keyBytes, + }, + ) + require.NoError(t, err) + + err = os.WriteFile( + keyPath, keyBuf.Bytes(), 0600, + ) + require.NoError(t, err) + }, + }, + { + name: "only cert exists", + setup: func(t *testing.T, certPath, keyPath string) { + // Create only a cert file. It simulates + // leftover from previous run. + certBytes, _ := genCertPair(t, false) + certBuf := &bytes.Buffer{} + err := pem.Encode( + certBuf, &pem.Block{ + Type: "CERTIFICATE", + Bytes: certBytes, + }, + ) + require.NoError(t, err) + + err = os.WriteFile( + certPath, certBuf.Bytes(), 0644, + ) + require.NoError(t, err) + }, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + certPath := tempDir + "/tls.cert" + keyPath := tempDir + "/tls.key" + + tc.setup(t, certPath, keyPath) + + cfg := &TLSManagerCfg{ + TLSCertPath: certPath, + TLSKeyPath: keyPath, + TLSCertDuration: testTLSCertDuration, + } + tlsManager := NewTLSManager(cfg) + + err := tlsManager.generateCertPair(keyRing) + require.NoError( + t, err, "should generate new cert pair when %s", + tc.name, + ) + + _, _, err = cert.GetCertBytesFromPath(certPath, keyPath) + require.NoError( + t, err, "should be able to load cert pair", + ) + }) + } +} From 76b07017454dbe6b65f3e45ca587c57e607bb44e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thiago=20Rom=C3=A3o=20Barcala?= Date: Thu, 27 Nov 2025 13:18:15 +0000 Subject: [PATCH 022/134] tls_manager.go: handle case when either TLS pair files exist --- tls_manager.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tls_manager.go b/tls_manager.go index 076cf44bc..242fd378b 100644 --- a/tls_manager.go +++ b/tls_manager.go @@ -208,8 +208,8 @@ func (t *TLSManager) generateOrRenewCert() (*tls.Config, error) { // is already written to disk, this function overwrites the plaintext key with // the encrypted form. func (t *TLSManager) generateCertPair(keyRing keychain.SecretKeyRing) error { - // Ensure we create TLS key and certificate if they don't exist. - if lnrpc.FileExists(t.cfg.TLSCertPath) || + // Ensure we create TLS key and certificate if they don't both exist. + if lnrpc.FileExists(t.cfg.TLSCertPath) && lnrpc.FileExists(t.cfg.TLSKeyPath) { // Handle discrepencies related to the TLSEncryptKey setting. From 222e038a77a321c95dcf6732a16ed5c56fcb5d71 Mon Sep 17 00:00:00 2001 From: Mohamed Awnallah Date: Thu, 4 Dec 2025 11:20:41 +0000 Subject: [PATCH 023/134] docs: update release notes --- docs/release-notes/release-notes-0.20.1.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/release-notes/release-notes-0.20.1.md b/docs/release-notes/release-notes-0.20.1.md index 844ae3089..f5b336a47 100644 --- a/docs/release-notes/release-notes-0.20.1.md +++ b/docs/release-notes/release-notes-0.20.1.md @@ -37,6 +37,11 @@ in the mission control store. Now we skip over potential errors and also delete them from the store. +* [Fixed an issue](https://github.com/lightningnetwork/lnd/pull/10399) where the + TLS manager would fail to start if only one of the TLS pair files (certificate + or key) existed. The manager now correctly regenerates both files when either + is missing, preventing "file not found" errors on startup. + # New Features ## Functional Enhancements From 540224239bc7c76f985580c199cd4a7dda341ce3 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Thu, 4 Dec 2025 11:23:42 +0200 Subject: [PATCH 024/134] graph/db/models: fix race condition in Node.PubKey The PubKey method had a race condition where concurrent calls could all pass the nil check and race to write to the cached pubKey field. This is a classic check-then-act race. Remove the caching entirely to fix the race. The overhead of parsing a public key is minimal and doesn't justify the added complexity and race risk of caching. --- graph/db/models/node.go | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/graph/db/models/node.go b/graph/db/models/node.go index 23d6a4268..91700f43a 100644 --- a/graph/db/models/node.go +++ b/graph/db/models/node.go @@ -18,7 +18,6 @@ import ( type Node struct { // PubKeyBytes is the raw bytes of the public key of the target node. PubKeyBytes [33]byte - pubKey *btcec.PublicKey // HaveNodeAnnouncement indicates whether we received a node // announcement for this particular node. If true, the remaining fields @@ -62,21 +61,8 @@ type Node struct { // PubKey is the node's long-term identity public key. This key will be used to // authenticated any advertisements/updates sent by the node. -// -// NOTE: By having this method to access an attribute, we ensure we only need -// to fully deserialize the pubkey if absolutely necessary. -func (l *Node) PubKey() (*btcec.PublicKey, error) { - if l.pubKey != nil { - return l.pubKey, nil - } - - key, err := btcec.ParsePubKey(l.PubKeyBytes[:]) - if err != nil { - return nil, err - } - l.pubKey = key - - return key, nil +func (n *Node) PubKey() (*btcec.PublicKey, error) { + return btcec.ParsePubKey(n.PubKeyBytes[:]) } // AuthSig is a signature under the advertised public key which serves to @@ -91,7 +77,6 @@ func (l *Node) AuthSig() (*ecdsa.Signature, error) { // AddPubKey is a setter-link method that can be used to swap out the public // key for a node. func (l *Node) AddPubKey(key *btcec.PublicKey) { - l.pubKey = key copy(l.PubKeyBytes[:], key.SerializeCompressed()) } From 550de8d34886bb20b1fe662a971c3b5e99c1675c Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Thu, 4 Dec 2025 11:23:53 +0200 Subject: [PATCH 025/134] graph/db/models: fix race conditions in ChannelEdgeInfo Both NodeKey1 and NodeKey2 methods had the same race condition as the Node.PubKey method, where concurrent calls could race to write to the cached fields. Remove the caching for the same reasons: parsing overhead is minimal and doesn't justify the complexity and race risk. --- graph/db/models/channel_edge_info.go | 36 +++------------------------- 1 file changed, 3 insertions(+), 33 deletions(-) diff --git a/graph/db/models/channel_edge_info.go b/graph/db/models/channel_edge_info.go index d19287571..efceed419 100644 --- a/graph/db/models/channel_edge_info.go +++ b/graph/db/models/channel_edge_info.go @@ -33,11 +33,9 @@ type ChannelEdgeInfo struct { // NodeKey1Bytes is the raw public key of the first node. NodeKey1Bytes [33]byte - nodeKey1 *btcec.PublicKey // NodeKey2Bytes is the raw public key of the first node. NodeKey2Bytes [33]byte - nodeKey2 *btcec.PublicKey // BitcoinKey1Bytes is the raw public key of the first node. BitcoinKey1Bytes [33]byte @@ -84,10 +82,8 @@ type ChannelEdgeInfo struct { func (c *ChannelEdgeInfo) AddNodeKeys(nodeKey1, nodeKey2, bitcoinKey1, bitcoinKey2 *btcec.PublicKey) { - c.nodeKey1 = nodeKey1 - copy(c.NodeKey1Bytes[:], c.nodeKey1.SerializeCompressed()) + copy(c.NodeKey1Bytes[:], nodeKey1.SerializeCompressed()) - c.nodeKey2 = nodeKey2 copy(c.NodeKey2Bytes[:], nodeKey2.SerializeCompressed()) c.bitcoinKey1 = bitcoinKey1 @@ -101,42 +97,16 @@ func (c *ChannelEdgeInfo) AddNodeKeys(nodeKey1, nodeKey2, bitcoinKey1, // the creation of this channel. A node is considered "first" if the // lexicographical ordering the its serialized public key is "smaller" than // that of the other node involved in channel creation. -// -// NOTE: By having this method to access an attribute, we ensure we only need -// to fully deserialize the pubkey if absolutely necessary. func (c *ChannelEdgeInfo) NodeKey1() (*btcec.PublicKey, error) { - if c.nodeKey1 != nil { - return c.nodeKey1, nil - } - - key, err := btcec.ParsePubKey(c.NodeKey1Bytes[:]) - if err != nil { - return nil, err - } - c.nodeKey1 = key - - return key, nil + return btcec.ParsePubKey(c.NodeKey1Bytes[:]) } // NodeKey2 is the identity public key of the "second" node that was involved in // the creation of this channel. A node is considered "second" if the // lexicographical ordering the its serialized public key is "larger" than that // of the other node involved in channel creation. -// -// NOTE: By having this method to access an attribute, we ensure we only need -// to fully deserialize the pubkey if absolutely necessary. func (c *ChannelEdgeInfo) NodeKey2() (*btcec.PublicKey, error) { - if c.nodeKey2 != nil { - return c.nodeKey2, nil - } - - key, err := btcec.ParsePubKey(c.NodeKey2Bytes[:]) - if err != nil { - return nil, err - } - c.nodeKey2 = key - - return key, nil + return btcec.ParsePubKey(c.NodeKey2Bytes[:]) } // BitcoinKey1 is the Bitcoin multi-sig key belonging to the first node, that From a83d1177b8d12b31e9d524ed284caf8d55e96a9e Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Thu, 4 Dec 2025 11:24:56 +0200 Subject: [PATCH 026/134] graph/db: fix race in DisconnectBlockAtHeight cache access The DisconnectBlockAtHeight method was modifying the rejectCache and chanCache without holding the cacheMu lock. This caused races with other operations that properly held the lock, such as AddChannelEdge which modifies the caches in its OnCommit callback while the batch scheduler holds cacheMu. Fix by acquiring cacheMu before removing channels from the caches. --- graph/db/sql_store.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/graph/db/sql_store.go b/graph/db/sql_store.go index 155515c2e..255d2aa71 100644 --- a/graph/db/sql_store.go +++ b/graph/db/sql_store.go @@ -2916,10 +2916,12 @@ func (s *SQLStore) DisconnectBlockAtHeight(height uint32) ( "height: %w", err) } + s.cacheMu.Lock() for _, channel := range removedChans { s.rejectCache.remove(channel.ChannelID) s.chanCache.remove(channel.ChannelID) } + s.cacheMu.Unlock() return removedChans, nil } From 439c3ede7d86566459ba47cb508701a80c552a89 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Tue, 9 Dec 2025 10:17:24 +0200 Subject: [PATCH 027/134] graph/db: fix Node receivers Let all the "Node" struct receivers be "n" in order to fix the linter check. --- graph/db/models/node.go | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/graph/db/models/node.go b/graph/db/models/node.go index 91700f43a..46e127044 100644 --- a/graph/db/models/node.go +++ b/graph/db/models/node.go @@ -70,44 +70,44 @@ func (n *Node) PubKey() (*btcec.PublicKey, error) { // // NOTE: By having this method to access an attribute, we ensure we only need // to fully deserialize the signature if absolutely necessary. -func (l *Node) AuthSig() (*ecdsa.Signature, error) { - return ecdsa.ParseSignature(l.AuthSigBytes) +func (n *Node) AuthSig() (*ecdsa.Signature, error) { + return ecdsa.ParseSignature(n.AuthSigBytes) } // AddPubKey is a setter-link method that can be used to swap out the public // key for a node. -func (l *Node) AddPubKey(key *btcec.PublicKey) { - copy(l.PubKeyBytes[:], key.SerializeCompressed()) +func (n *Node) AddPubKey(key *btcec.PublicKey) { + copy(n.PubKeyBytes[:], key.SerializeCompressed()) } // NodeAnnouncement retrieves the latest node announcement of the node. -func (l *Node) NodeAnnouncement(signed bool) (*lnwire.NodeAnnouncement1, +func (n *Node) NodeAnnouncement(signed bool) (*lnwire.NodeAnnouncement1, error) { - if !l.HaveNodeAnnouncement { + if !n.HaveNodeAnnouncement { return nil, fmt.Errorf("node does not have node announcement") } - alias, err := lnwire.NewNodeAlias(l.Alias) + alias, err := lnwire.NewNodeAlias(n.Alias) if err != nil { return nil, err } nodeAnn := &lnwire.NodeAnnouncement1{ - Features: l.Features.RawFeatureVector, - NodeID: l.PubKeyBytes, - RGBColor: l.Color, + Features: n.Features.RawFeatureVector, + NodeID: n.PubKeyBytes, + RGBColor: n.Color, Alias: alias, - Addresses: l.Addresses, - Timestamp: uint32(l.LastUpdate.Unix()), - ExtraOpaqueData: l.ExtraOpaqueData, + Addresses: n.Addresses, + Timestamp: uint32(n.LastUpdate.Unix()), + ExtraOpaqueData: n.ExtraOpaqueData, } if !signed { return nodeAnn, nil } - sig, err := lnwire.NewSigFromECDSARawSignature(l.AuthSigBytes) + sig, err := lnwire.NewSigFromECDSARawSignature(n.AuthSigBytes) if err != nil { return nil, err } From ad87b492d119307327f25a6a7e51f5c728245b95 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Mon, 8 Dec 2025 13:50:28 +0200 Subject: [PATCH 028/134] docs: add release notes for race condition fixes --- docs/release-notes/release-notes-0.20.1.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/release-notes/release-notes-0.20.1.md b/docs/release-notes/release-notes-0.20.1.md index 9dc917fbd..03f980848 100644 --- a/docs/release-notes/release-notes-0.20.1.md +++ b/docs/release-notes/release-notes-0.20.1.md @@ -47,6 +47,14 @@ or key) existed. The manager now correctly regenerates both files when either is missing, preventing "file not found" errors on startup. +* [Fixed race conditions](https://github.com/lightningnetwork/lnd/pull/10433) in + the channel graph database. The `Node.PubKey()` and + `ChannelEdgeInfo.NodeKey1/NodeKey2()` methods had check-then-act races when + caching parsed public keys. Additionally, `DisconnectBlockAtHeight` was + accessing the reject and channel caches without proper locking. The caching + has been removed from the public key parsing methods, and proper mutex + protection has been added to the cache access in `DisconnectBlockAtHeight`. + # New Features ## Functional Enhancements From c079362c2c368d4ecd5800011c230ccd078906ed Mon Sep 17 00:00:00 2001 From: ziggie Date: Sun, 7 Dec 2025 11:08:47 +0100 Subject: [PATCH 029/134] graphdb: fix potential sql tx exhaustion We should avoid taking the lock of a mutex inside transaction. Currently we also take this lock in other places and there is a chance that in case the application lock aquires the lock but all transactions are already blocked waiting for the mutex to unlock, we end up in a deadlock. --- graph/db/kv_store.go | 14 ++++++++------ graph/db/sql_store.go | 12 ++++++++++-- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/graph/db/kv_store.go b/graph/db/kv_store.go index cc9a14889..ec55ee3be 100644 --- a/graph/db/kv_store.go +++ b/graph/db/kv_store.go @@ -2116,6 +2116,13 @@ func (c *KVStore) fetchNextChanUpdateBatch( batch []ChannelEdge hasMore bool ) + + // Acquire read lock before starting transaction to ensure + // consistent lock ordering (cacheMu -> DB) and prevent + // deadlock with write operations. + c.cacheMu.RLock() + defer c.cacheMu.RUnlock() + err := kvdb.View(c.db, func(tx kvdb.RTx) error { edges := tx.ReadBucket(edgeBucket) if edges == nil { @@ -2195,9 +2202,7 @@ func (c *KVStore) fetchNextChanUpdateBatch( continue } - // Before we read the edge info, we'll see if this - // element is already in the cache or not. - c.cacheMu.RLock() + // Check cache (we already hold shared read lock). if channel, ok := c.chanCache.get(chanIDInt); ok { state.edgesSeen[chanIDInt] = struct{}{} @@ -2208,11 +2213,8 @@ func (c *KVStore) fetchNextChanUpdateBatch( indexKey, _ = updateCursor.Next() - c.cacheMu.RUnlock() - continue } - c.cacheMu.RUnlock() // The edge wasn't in the cache, so we'll fetch it along // w/ the edge policies and nodes. diff --git a/graph/db/sql_store.go b/graph/db/sql_store.go index f67894e4c..2d85971ac 100644 --- a/graph/db/sql_store.go +++ b/graph/db/sql_store.go @@ -1126,6 +1126,11 @@ func (s *SQLStore) ChanUpdatesInHorizon(startTime, endTime time.Time, for hasMore { var batch []ChannelEdge + // Acquire read lock before starting transaction to + // ensure consistent lock ordering (cacheMu -> DB) and + // prevent deadlock with write operations. + s.cacheMu.RLock() + err := s.db.ExecTx(ctx, sqldb.ReadTxOpt(), func(db SQLQueries) error { //nolint:ll @@ -1178,11 +1183,11 @@ func (s *SQLStore) ChanUpdatesInHorizon(startTime, endTime time.Time, continue } - s.cacheMu.RLock() + // Check cache (we already hold + // shared read lock). channel, ok := s.chanCache.get( chanIDInt, ) - s.cacheMu.RUnlock() if ok { hits++ total++ @@ -1216,6 +1221,9 @@ func (s *SQLStore) ChanUpdatesInHorizon(startTime, endTime time.Time, ) }) + // Release read lock after transaction completes. + s.cacheMu.RUnlock() + if err != nil { log.Errorf("ChanUpdatesInHorizon "+ "batch error: %v", err) From 0c7db1a206a56021fb80ed5849e3593a61af4a44 Mon Sep 17 00:00:00 2001 From: ziggie Date: Tue, 9 Dec 2025 11:50:39 +0100 Subject: [PATCH 030/134] docs: add release-notes for LND 20.1 --- docs/release-notes/release-notes-0.20.1.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/release-notes/release-notes-0.20.1.md b/docs/release-notes/release-notes-0.20.1.md index 59370eaae..32ae26b31 100644 --- a/docs/release-notes/release-notes-0.20.1.md +++ b/docs/release-notes/release-notes-0.20.1.md @@ -32,6 +32,10 @@ addresses](https://github.com/lightningnetwork/lnd/pull/10341) were added to the node announcement and `getinfo` output. +* [Fix potential sql tx exhaustion + issue](https://github.com/lightningnetwork/lnd/pull/10428) in LND which might + happen when running postgres with a limited number of connections configured. + # New Features ## Functional Enhancements From c32cbd940a88841cd5cc64e1f3041939845d127b Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Thu, 11 Dec 2025 17:46:25 +0200 Subject: [PATCH 031/134] funding: export MakeFundingScript So that we can re-use this helper else where. --- funding/manager.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/funding/manager.go b/funding/manager.go index 8176e6aa2..616ddd83a 100644 --- a/funding/manager.go +++ b/funding/manager.go @@ -1361,7 +1361,7 @@ func (f *Manager) advancePendingChannelState(channel *channeldb.OpenChannel, } txid := &channel.FundingOutpoint.Hash - fundingScript, err := makeFundingScript(channel) + fundingScript, err := MakeFundingScript(channel) if err != nil { log.Errorf("unable to create funding script for "+ "ChannelPoint(%v): %v", @@ -3037,9 +3037,9 @@ func (f *Manager) waitForFundingWithTimeout( } } -// makeFundingScript re-creates the funding script for the funding transaction +// MakeFundingScript re-creates the funding script for the funding transaction // of the target channel. -func makeFundingScript(channel *channeldb.OpenChannel) ([]byte, error) { +func MakeFundingScript(channel *channeldb.OpenChannel) ([]byte, error) { localKey := channel.LocalChanCfg.MultiSigKey.PubKey remoteKey := channel.RemoteChanCfg.MultiSigKey.PubKey @@ -3086,7 +3086,7 @@ func (f *Manager) waitForFundingConfirmation( // Register with the ChainNotifier for a notification once the funding // transaction reaches `numConfs` confirmations. txid := completeChan.FundingOutpoint.Hash - fundingScript, err := makeFundingScript(completeChan) + fundingScript, err := MakeFundingScript(completeChan) if err != nil { log.Errorf("unable to create funding script for "+ "ChannelPoint(%v): %v", completeChan.FundingOutpoint, @@ -3802,7 +3802,7 @@ func (f *Manager) annAfterSixConfs(completeChan *channeldb.OpenChannel, shortChanID.ToUint64(), completeChan.FundingOutpoint, numConfs) - fundingScript, err := makeFundingScript(completeChan) + fundingScript, err := MakeFundingScript(completeChan) if err != nil { return fmt.Errorf("unable to create funding script "+ "for ChannelPoint(%v): %v", From 7d695f581f2c183f64a6326504ba801ff9ecff07 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Thu, 11 Dec 2025 17:46:48 +0200 Subject: [PATCH 032/134] localchans: populate funding script for missing edges When creating a missing edge, we need to populate the funding script too so that the graph builder can update its ChainView appropriately. We use the MakeFundingScript helper from the funding package which ensures that we are using the same logic for creating a funding script as is used for any of the channels that we own. --- routing/localchans/manager.go | 18 +++++++++++++----- routing/localchans/manager_test.go | 12 ++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/routing/localchans/manager.go b/routing/localchans/manager.go index b1d281187..a48486e7b 100644 --- a/routing/localchans/manager.go +++ b/routing/localchans/manager.go @@ -13,6 +13,7 @@ import ( "github.com/lightningnetwork/lnd/channeldb" "github.com/lightningnetwork/lnd/discovery" "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/funding" "github.com/lightningnetwork/lnd/graph/db/models" "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnwire" @@ -321,12 +322,19 @@ func (r *Manager) createEdge(channel *channeldb.OpenChannel, shortChanID = channel.ZeroConfRealScid() } + fundingScript, err := funding.MakeFundingScript(channel) + if err != nil { + return nil, nil, fmt.Errorf("unable to create funding "+ + "script: %v", err) + } + info := &models.ChannelEdgeInfo{ - ChannelID: shortChanID.ToUint64(), - ChainHash: channel.ChainHash, - Features: lnwire.EmptyFeatureVector(), - Capacity: channel.Capacity, - ChannelPoint: channel.FundingOutpoint, + ChannelID: shortChanID.ToUint64(), + ChainHash: channel.ChainHash, + Features: lnwire.EmptyFeatureVector(), + Capacity: channel.Capacity, + ChannelPoint: channel.FundingOutpoint, + FundingScript: fn.Some(fundingScript), } copy(info.NodeKey1Bytes[:], nodeKey1Bytes) diff --git a/routing/localchans/manager_test.go b/routing/localchans/manager_test.go index a2e7164b2..5df344bba 100644 --- a/routing/localchans/manager_test.go +++ b/routing/localchans/manager_test.go @@ -13,6 +13,8 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/lightningnetwork/lnd/channeldb" "github.com/lightningnetwork/lnd/discovery" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/funding" "github.com/lightningnetwork/lnd/graph/db/models" "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lnrpc" @@ -385,6 +387,10 @@ func TestCreateEdgeLower(t *testing.T) { Index: 0, }, } + + fundingScript, err := funding.MakeFundingScript(channel) + require.NoError(t, err) + expectedInfo := &models.ChannelEdgeInfo{ ChannelID: 8, ChainHash: channel.ChainHash, @@ -399,6 +405,7 @@ func TestCreateEdgeLower(t *testing.T) { remoteMultisigKey.SerializeCompressed()), AuthProof: nil, ExtraOpaqueData: nil, + FundingScript: fn.Some(fundingScript), } expectedEdge := &models.ChannelEdgePolicy{ ChannelID: 8, @@ -473,6 +480,10 @@ func TestCreateEdgeHigher(t *testing.T) { Index: 0, }, } + + fundingScript, err := funding.MakeFundingScript(channel) + require.NoError(t, err) + expectedInfo := &models.ChannelEdgeInfo{ ChannelID: 8, ChainHash: channel.ChainHash, @@ -487,6 +498,7 @@ func TestCreateEdgeHigher(t *testing.T) { localMultisigKey.SerializeCompressed()), AuthProof: nil, ExtraOpaqueData: nil, + FundingScript: fn.Some(fundingScript), } expectedEdge := &models.ChannelEdgePolicy{ ChannelID: 8, From 53994daf1337e65252c488127440d0d1ac639acd Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Tue, 9 Dec 2025 11:20:25 +0200 Subject: [PATCH 033/134] docs: update release notes --- docs/release-notes/release-notes-0.20.1.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/release-notes/release-notes-0.20.1.md b/docs/release-notes/release-notes-0.20.1.md index b1c00de95..90c049646 100644 --- a/docs/release-notes/release-notes-0.20.1.md +++ b/docs/release-notes/release-notes-0.20.1.md @@ -58,6 +58,11 @@ * [Fix potential sql tx exhaustion issue](https://github.com/lightningnetwork/lnd/pull/10428) in LND which might happen when running postgres with a limited number of connections configured. + +* Fix a bug where [missing edges for own channels could not be added to the + graph DB](https://github.com/lightningnetwork/lnd/pull/10443) + due to validation checks in the graph Builder that were resurfaced after the + graph refactor work. # New Features From 246abd16978df2cde821aa5956ca46e034f2c3cd Mon Sep 17 00:00:00 2001 From: ziggie Date: Sat, 29 Nov 2025 00:04:53 +0100 Subject: [PATCH 034/134] graph/db: fix HasNode comment The comment was incorrectly referring to HasLightningNode but the function is named HasNode. Update the comment to match the actual function name. --- graph/db/kv_store.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/graph/db/kv_store.go b/graph/db/kv_store.go index ec55ee3be..e47f8e28f 100644 --- a/graph/db/kv_store.go +++ b/graph/db/kv_store.go @@ -3441,11 +3441,10 @@ func (c *KVStore) fetchLightningNode(tx kvdb.RTx, return node, nil } -// HasLightningNode determines if the graph has a vertex identified by the -// target node identity public key. If the node exists in the database, a -// timestamp of when the data for the node was lasted updated is returned along -// with a true boolean. Otherwise, an empty time.Time is returned with a false -// boolean. +// HasNode determines if the graph has a vertex identified by the target node +// identity public key. If the node exists in the database, a timestamp of when +// the data for the node was lasted updated is returned along with a true +// boolean. Otherwise, an empty time.Time is returned with a false boolean. func (c *KVStore) HasNode(_ context.Context, nodePub [33]byte) (time.Time, bool, error) { From 04ebd363c2f43c540b78a7dc4f0bd307a2b60222 Mon Sep 17 00:00:00 2001 From: ziggie Date: Sat, 29 Nov 2025 00:09:04 +0100 Subject: [PATCH 035/134] routerrpc: add HasNode backend function for LSP heuristic This commit adds the HasNode function to the RouterBackend struct, which checks if a node exists in the graph (i.e., has public channels). This function is needed by the LSP detection heuristic to determine if a node is publicly reachable. The function is wired up in rpcserver.go to query the graph database. --- lnrpc/routerrpc/router_backend.go | 5 +++++ rpcserver.go | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/lnrpc/routerrpc/router_backend.go b/lnrpc/routerrpc/router_backend.go index 377d0be3e..b8bc46670 100644 --- a/lnrpc/routerrpc/router_backend.go +++ b/lnrpc/routerrpc/router_backend.go @@ -63,6 +63,11 @@ type RouterBackend struct { FetchChannelEndpoints func(chanID uint64) (route.Vertex, route.Vertex, error) + // HasNode returns true if the node exists in the graph (i.e., has + // public channels), false otherwise. This means the node is a public + // node and should be reachable. + HasNode func(nodePub route.Vertex) (bool, error) + // FindRoute is a closure that abstracts away how we locate/query for // routes. FindRoute func(*routing.RouteRequest) (*route.Route, float64, error) diff --git a/rpcserver.go b/rpcserver.go index d3d3c5180..64eb40fb8 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -685,6 +685,8 @@ func newRPCServer(cfg *Config, interceptorChain *rpcperms.InterceptorChain, // addDeps populates all dependencies needed by the RPC server, and any // of the sub-servers that it maintains. When this is done, the RPC server can // be started, and start accepting RPC calls. +// +//nolint:funlen func (r *rpcServer) addDeps(ctx context.Context, s *server, macService *macaroons.Service, subServerCgs *subRPCServerConfigs, atpl *autopilot.Manager, @@ -734,6 +736,11 @@ func (r *rpcServer) addDeps(ctx context.Context, s *server, return info.NodeKey1Bytes, info.NodeKey2Bytes, nil }, + HasNode: func(nodePub route.Vertex) (bool, error) { + _, exists, err := graph.HasNode(ctx, nodePub) + + return exists, err + }, FindRoute: s.chanRouter.FindRoute, MissionControl: s.defaultMC, ActiveNetParams: r.cfg.ActiveNetParams.Params, From f1fc329eb276363e8f632dfc8c31000d78bdebb1 Mon Sep 17 00:00:00 2001 From: ziggie Date: Sat, 29 Nov 2025 00:10:17 +0100 Subject: [PATCH 036/134] routerrpc: implement LSP heuristic and multi-LSP worst-case probing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit implements a comprehensive LSP (Lightning Service Provider) detection heuristic and updates the payment probing logic to handle multiple LSPs with worst-case fee estimation. Key changes: 1. LSP Detection Heuristic (isLSP function): Implements three rules to detect LSP setups: - Rule 1: If invoice target is public → NOT an LSP (route directly) - Rule 2: If at least one destination hop is public → IS an LSP - Rule 3: If all destination hops are private → NOT an LSP 2. LSP Route Preparation (prepareLspRouteHints function): - Groups route hints by unique public LSP nodes - Filters out non-LSP routes based on the heuristic - Tracks worst-case fees and CLTV delays for each LSP - Returns adjusted route hints with LSP hop stripped 3. Multi-LSP Probing (probePaymentRequest updates): - Probes up to 3 unique LSPs maximum (griefing protection) - Selects the WORST-CASE (most expensive) route for conservative fee estimation - Adds comprehensive debug logging for worst-case selection process - Properly formats vertex logging using %v (calls Vertex.String()) The worst-case approach ensures users won't be surprised by higher fees when the actual payment is sent, providing a more conservative and reliable fee estimate. This commit also adds extensive unit test coverage for the LSP detection heuristic and route preparation logic. TestIsLsp: - Edge cases: empty route hints, nil scenarios - Rule 1: Public invoice target (3 tests) - Rule 2: All private destination hops (4 tests) - Rule 3: At least one public destination hop (6 tests) TestPrepareLspRouteHints: - LSP grouping and filtering logic - Worst-case fee selection across route hints - Worst-case CLTV delta tracking - Adjusted route hints validation (LSP hop stripped) - Multi-LSP scenarios with different fees --- lnrpc/routerrpc/router_server.go | 461 ++++++++++++------ lnrpc/routerrpc/router_server_test.go | 663 +++++++++++++++++++------- 2 files changed, 790 insertions(+), 334 deletions(-) diff --git a/lnrpc/routerrpc/router_server.go b/lnrpc/routerrpc/router_server.go index 1dbc19e47..db04526d4 100644 --- a/lnrpc/routerrpc/router_server.go +++ b/lnrpc/routerrpc/router_server.go @@ -1,7 +1,6 @@ package routerrpc import ( - "bytes" "context" crand "crypto/rand" "errors" @@ -44,6 +43,12 @@ const ( // DefaultPaymentTimeout is the default value of time we should spend // when attempting to fulfill the payment. DefaultPaymentTimeout int32 = 60 + + // MaxLspsToProbe is the maximum number of LSPs to probe when + // estimating fees for worst-case fee estimation. This is a + // precautionary measure to prevent the estimation from taking too + // long, and it is also a griefing protection. + MaxLspsToProbe = 3 ) var ( @@ -171,10 +176,9 @@ var ( DefaultRouterMacFilename = "router.macaroon" ) -// FetchChannelEndpoints returns the pubkeys of both endpoints of the -// given channel id if it exists in the graph. -type FetchChannelEndpoints func(chanID uint64) (route.Vertex, route.Vertex, - error) +// HasNode returns true if the node exists in the graph (i.e., has public +// channels), false otherwise. +type HasNode func(nodePub route.Vertex) (bool, error) // ServerShell is a shell struct holding a reference to the actual sub-server. // It is used to register the gRPC sub-server with the root server before we @@ -561,7 +565,8 @@ func (s *Server) probePaymentRequest(ctx context.Context, paymentRequest string, // If the hints don't indicate an LSP then chances are that our probe // payment won't be blocked along the route to the destination. We send // a probe payment with unmodified route hints. - if !isLSP(hints, s.cfg.RouterBackend.FetchChannelEndpoints) { + invoiceTargetCompressed := payReq.Destination.SerializeCompressed() + if !isLSP(hints, invoiceTargetCompressed, s.cfg.RouterBackend.HasNode) { log.Infof("No LSP detected, probing destination %x", probeRequest.Dest) @@ -569,200 +574,342 @@ func (s *Server) probePaymentRequest(ctx context.Context, paymentRequest string, return s.sendProbePayment(ctx, probeRequest) } - // If the heuristic indicates an LSP we modify the route hints to allow - // probing the LSP. - lspAdjustedRouteHints, lspHint, err := prepareLspRouteHints( - hints, *payReq.MilliSat, + // If the heuristic indicates an LSP, we filter and group route hints by + // public LSP nodes, then probe each unique LSP separately and return + // the cheapest route. + lspGroups, err := prepareLspRouteHints( + hints, *payReq.MilliSat, s.cfg.RouterBackend.HasNode, ) if err != nil { return nil, err } - // Set the destination to the LSP node ID. - lspDest := lspHint.NodeID.SerializeCompressed() - probeRequest.Dest = lspDest + log.Infof("LSP detected, found %d unique public LSP node(s) to probe", + len(lspGroups)) - log.Infof("LSP detected, probing LSP with destination: %x", lspDest) - - // The adjusted route hints serve the payment probe to find the last - // public hop to the LSP on the route. - if len(lspAdjustedRouteHints) > 0 { - probeRequest.RouteHints = invoicesrpc.CreateRPCRouteHints( - lspAdjustedRouteHints, - ) + // Probe up to MaxLspsToProbe LSPs and track the most expensive route + // for worst-case fee estimation. + if len(lspGroups) > MaxLspsToProbe { + log.Debugf("Limiting LSP probes from %d to %d for worst-case "+ + "fee estimation", len(lspGroups), MaxLspsToProbe) } + var ( + worstCaseResp *RouteFeeResponse + worstCaseLspDest route.Vertex + probeCount int + ) - // The payment probe will be able to calculate the fee up until the LSP - // node. The fee of the last hop has to be calculated manually. Since - // the last hop's fee amount has to be sent across the payment path we - // have to add it to the original payment amount. Only then will the - // payment probe be able to determine the correct fee to the last hop - // prior to the private destination. For example, if the user wants to - // send 1000 sats to a private destination and the last hop's fee is 10 - // sats, then 1010 sats will have to arrive at the last hop. This means - // that the probe has to be dispatched with 1010 sats to correctly - // calculate the routing fee. - // - // Calculate the hop fee for the last hop manually. - hopFee := lspHint.HopFee(*payReq.MilliSat) - if err != nil { - return nil, err - } + for lspKey, group := range lspGroups { + if probeCount >= MaxLspsToProbe { + break + } + probeCount++ - // Add the last hop's fee to the requested payment amount that we want - // to get an estimate for. - probeRequest.AmtMsat += int64(hopFee) + lspHint := group.LspHopHint - // Use the hop hint's cltv delta as the payment request's final cltv - // delta. The actual final cltv delta of the invoice will be added to - // the payment probe's cltv delta. - probeRequest.FinalCltvDelta = int32(lspHint.CLTVExpiryDelta) + log.Infof("Probing LSP with destination: %v", lspKey) - // Dispatch the payment probe with adjusted fee amount. - resp, err := s.sendProbePayment(ctx, probeRequest) - if err != nil { - return nil, fmt.Errorf("failed to send probe payment to "+ - "LSP with destination %x: %w", lspDest, err) - } + // Create a new probe request for this LSP. + lspProbeRequest := &SendPaymentRequest{ + TimeoutSeconds: probeRequest.TimeoutSeconds, + Dest: lspKey[:], + MaxParts: probeRequest.MaxParts, + AllowSelfPayment: probeRequest.AllowSelfPayment, + AmtMsat: amtMsat, + PaymentHash: probeRequest.PaymentHash, + FeeLimitSat: probeRequest.FeeLimitSat, + FinalCltvDelta: int32(lspHint.CLTVExpiryDelta), + DestFeatures: probeRequest.DestFeatures, + } - // If the payment probe failed we only return the failure reason and - // leave the probe result params unaltered. - if resp.FailureReason != lnrpc.PaymentFailureReason_FAILURE_REASON_NONE { //nolint:ll - return resp, nil - } + // Copy the payment address if present. + if len(probeRequest.PaymentAddr) > 0 { + copy( + lspProbeRequest.PaymentAddr, + probeRequest.PaymentAddr, + ) + } - // The probe succeeded, so we can add the last hop's fee to fee the - // payment probe returned. - resp.RoutingFeeMsat += int64(hopFee) + // Set the adjusted route hints for this LSP. + if len(group.AdjustedRouteHints) > 0 { + lspProbeRequest.RouteHints = invoicesrpc. + CreateRPCRouteHints(group.AdjustedRouteHints) + } - // Add the final cltv delta of the invoice to the payment probe's total - // cltv delta. This is the cltv delta for the hop behind the LSP. - resp.TimeLockDelay += int64(payReq.MinFinalCLTVExpiry()) + // Calculate the hop fee for the last hop manually. + hopFee := lspHint.HopFee(*payReq.MilliSat) - return resp, nil -} + // Add the last hop's fee to the probe amount. + lspProbeRequest.AmtMsat += int64(hopFee) -// isLSP checks if the route hints indicate an LSP. An LSP is indicated with -// true if the destination hop hint in each route hint has the same node id, -// false otherwise. If the destination hop hint of any route hint contains a -// public channel, the function returns false because we can directly send a -// probe to the final destination. -func isLSP(routeHints [][]zpay32.HopHint, - fetchChannelEndpoints FetchChannelEndpoints) bool { - - if len(routeHints) == 0 || len(routeHints[0]) == 0 { - return false - } - - destHopHint := routeHints[0][len(routeHints[0])-1] - - // If the destination hop hint of the first route hint contains a public - // channel we can send a probe to it directly, hence we don't signal an - // LSP. - _, _, err := fetchChannelEndpoints(destHopHint.ChannelID) - if err == nil { - return false - } - - for i := 1; i < len(routeHints); i++ { - // Skip empty route hints. - if len(routeHints[i]) == 0 { + // Dispatch the payment probe for this LSP. + resp, err := s.sendProbePayment(ctx, lspProbeRequest) + if err != nil { + log.Warnf("Failed to probe LSP %v: %v", lspKey, err) continue } - lastHop := routeHints[i][len(routeHints[i])-1] + // If the probe failed, skip this LSP. + if resp.FailureReason != + lnrpc.PaymentFailureReason_FAILURE_REASON_NONE { - // If the last hop hint of any route hint contains a public - // channel we can send a probe to it directly, hence we don't - // signal an LSP. - _, _, err = fetchChannelEndpoints(lastHop.ChannelID) - if err == nil { - return false + log.Debugf("Probe to LSP %v failed with reason: %v", + lspKey, resp.FailureReason) + + continue } - matchesDestNode := bytes.Equal( - lastHop.NodeID.SerializeCompressed(), - destHopHint.NodeID.SerializeCompressed(), - ) - if !matchesDestNode { + // The probe succeeded, add the last hop's fee. + resp.RoutingFeeMsat += int64(hopFee) + + // Add the final cltv delta of the invoice. + resp.TimeLockDelay += int64(payReq.MinFinalCLTVExpiry()) + + log.Infof("Probe to LSP %v succeeded with fee: %d msat", + lspKey, resp.RoutingFeeMsat) + + // Track the most expensive route for worst-case estimation. + // We solely consider the routing fee for the worst-case + // estimation. + if worstCaseResp == nil || + resp.RoutingFeeMsat > worstCaseResp.RoutingFeeMsat { + + if worstCaseResp != nil { + log.Debugf("LSP %v has higher fee "+ + "(%d msat) than current worst-case "+ + "%v (%d msat), updating worst-case "+ + "estimate", lspKey, + resp.RoutingFeeMsat, worstCaseLspDest, + worstCaseResp.RoutingFeeMsat) + } + + worstCaseResp = resp + worstCaseLspDest = lspKey + } else { + log.Debugf("LSP %v fee (%d msat) is lower than "+ + "current worst-case %v (%d msat), keeping "+ + "worst-case estimate", lspKey, + resp.RoutingFeeMsat, worstCaseLspDest, + worstCaseResp.RoutingFeeMsat) + } + } + + // If no LSP probe succeeded, return an error. + if worstCaseResp == nil { + return nil, fmt.Errorf("all LSP probe payments failed") + } + + log.Infof("Returning worst-case route via LSP %v with fee: %d msat, "+ + "timelock: %d", worstCaseLspDest, worstCaseResp.RoutingFeeMsat, + worstCaseResp.TimeLockDelay) + + return worstCaseResp, nil +} + +// isLSP checks if the route hints indicate an LSP setup. An LSP setup is +// identified when the invoice destination is private but the final hop in the +// route hints is a public node (the LSP). This function implements three rules: +// +// 1. If the invoice target is a public node (exists in graph) => isLsp = false +// We can route directly to the target, so no LSP is involved. +// +// 2. If at least one destination hop hint (last hop in route hint) is public +// => isLsp = true. The public destination hop is the LSP, and the actual +// invoice target is a private node behind it. +// +// 3. If all destination hop hints are private nodes => isLsp = false. +// We assume this is NOT an LSP setup. Instead, we expect the route hints +// contain public nodes earlier in the path (not the final hop) that our +// pathfinder can route to. For example: +// The pathfinder will route to PublicNode and use the hints from there. +// Note: If no public nodes exist anywhere in the route hints, the +// destination would be unreachable (malformed invoice), but we don't +// validate that here. +func isLSP(routeHints [][]zpay32.HopHint, invoiceTarget []byte, + hasNode HasNode) bool { + + if len(routeHints) == 0 || len(routeHints[0]) == 0 { + log.Debugf("No route hints provided, this is not an LSP setup") + return false + } + + // Rule 1: If the invoice target is a public node (exists in the graph), + // we can route directly to it, so it's not an LSP setup. + if len(invoiceTarget) > 0 { + var targetVertex route.Vertex + copy(targetVertex[:], invoiceTarget) + + isPublic, err := hasNode(targetVertex) + if err != nil { + log.Warnf("Failed to check if invoice target %x is "+ + "public: %v", invoiceTarget, err) + + return false + } + if isPublic { + log.Infof("Invoice target %x is a public node in the "+ + "graph, this is NOT an LSP setup", + invoiceTarget) + return false } } - // We ensured that the destination hop hint doesn't contain a public - // channel, and that all destination hop hints of all route hints match, - // so we signal an LSP. - return true + for _, hopHints := range routeHints { + // Skip empty route hints. + if len(hopHints) == 0 { + continue + } + + lastHop := hopHints[len(hopHints)-1] + lastHopNodeCompressed := lastHop.NodeID.SerializeCompressed() + + // Check if this destination hop hint node is public. + // Rule 2: If we find a public node, we can exit early. + var lastHopVertex route.Vertex + copy(lastHopVertex[:], lastHopNodeCompressed) + + isPublic, err := hasNode(lastHopVertex) + if err != nil { + log.Warnf("Failed to check if destination hop "+ + "hint %x is public: %v", lastHopNodeCompressed, + err) + + continue + } + if isPublic { + log.Infof("Destination hop hint %x is a public node, "+ + "this is an LSP setup", lastHopNodeCompressed) + + return true + } + } + + // Rule 3: If all destination hop hints are private nodes (not in the + // graph), this is NOT an LSP setup. We assume the route hints contain + // public nodes earlier in the path that we can route through using + // standard pathfinding with the hints. + log.Infof("All destination hop hints are private, this is NOT an " + + "LSP setup") + + return false +} + +// LspRouteGroup represents a group of route hints that share the same public +// LSP destination node. This is needed when probing LSPs separately to find +// the cheapest route. +type LspRouteGroup struct { + // LspHopHint is the hop hint for the LSP node with worst-case fees and + // CLTV delta. + LspHopHint *zpay32.HopHint + + // AdjustedRouteHints are the route hints with the LSP hop stripped off. + AdjustedRouteHints [][]zpay32.HopHint } // prepareLspRouteHints assumes that the isLsp heuristic returned true for the -// route hints passed in here. It constructs a modified list of route hints that -// allows the caller to probe the LSP, which itself is returned as a separate -// hop hint. +// route hints passed in here. It filters route hints to only include those with +// public destination nodes, groups them by unique LSP node, and returns a map +// of LSP groups keyed by the LSP node's compressed public key. func prepareLspRouteHints(routeHints [][]zpay32.HopHint, - amt lnwire.MilliSatoshi) ([][]zpay32.HopHint, *zpay32.HopHint, error) { + amt lnwire.MilliSatoshi, + hasNode HasNode) (map[route.Vertex]*LspRouteGroup, error) { + // This should never happen, but we check for it for completeness. + // Because the isLSP heuristic already checked that the route hints are + // not empty. if len(routeHints) == 0 { - return nil, nil, fmt.Errorf("no route hints provided") + return nil, fmt.Errorf("no route hints provided") } - // Create the LSP hop hint. We are probing for the worst case fee and - // cltv delta. So we look for the max values amongst all LSP hop hints. - refHint := routeHints[0][len(routeHints[0])-1] - refHint.CLTVExpiryDelta = maxLspCltvDelta(routeHints) - refHint.FeeBaseMSat, refHint.FeeProportionalMillionths = maxLspFee( - routeHints, amt, - ) + // Map to group route hints by LSP node pubkey. + lspGroups := make(map[route.Vertex]*LspRouteGroup) - // We construct a modified list of route hints that allows the caller to - // probe the LSP. - adjustedHints := make([][]zpay32.HopHint, 0, len(routeHints)) + for _, routeHint := range routeHints { + // Skip empty route hints. + if len(routeHint) == 0 { + continue + } - // Strip off the LSP hop hint from all route hints. - for i := 0; i < len(routeHints); i++ { - hint := routeHints[i] - if len(hint) > 1 { - adjustedHints = append( - adjustedHints, hint[:len(hint)-1], + // Get the destination hop hint (last hop in the route). + destHop := routeHint[len(routeHint)-1] + destNodeCompressed := destHop.NodeID.SerializeCompressed() + + // Check if this destination node is public. + var destVertex route.Vertex + copy(destVertex[:], destNodeCompressed) + + isPublic, err := hasNode(destVertex) + if err != nil { + log.Warnf("Failed to check if dest hop hint %x is "+ + "public: %v", destNodeCompressed, err) + + continue + } + + // Skip private destination nodes - we only probe public LSPs. + if !isPublic { + log.Debugf("Skipping route hint with private dest "+ + "node %x", destNodeCompressed) + + continue + } + + // Use the compressed pubkey as the map key. + var lspKey route.Vertex + copy(lspKey[:], destNodeCompressed) + + // Get or create the LSP group for this node. + group, exists := lspGroups[lspKey] + if !exists { + //nolint:ll + lspHop := zpay32.HopHint{ + NodeID: destHop.NodeID, + ChannelID: destHop.ChannelID, + FeeBaseMSat: destHop.FeeBaseMSat, + FeeProportionalMillionths: destHop.FeeProportionalMillionths, + CLTVExpiryDelta: destHop.CLTVExpiryDelta, + } + group = &LspRouteGroup{ + LspHopHint: &lspHop, + AdjustedRouteHints: make([][]zpay32.HopHint, 0), + } + lspGroups[lspKey] = group + } + + // Update the LSP hop hint with worst-case (max) fees and CLTV. + hopFee := destHop.HopFee(amt) + currentMaxFee := group.LspHopHint.HopFee(amt) + if hopFee > currentMaxFee { + group.LspHopHint.FeeBaseMSat = destHop.FeeBaseMSat + group.LspHopHint.FeeProportionalMillionths = destHop. + FeeProportionalMillionths + } + + if destHop.CLTVExpiryDelta > group.LspHopHint.CLTVExpiryDelta { + group.LspHopHint.CLTVExpiryDelta = destHop. + CLTVExpiryDelta + } + + // Add the route hint with the LSP hop stripped off (if there + // are hops before the LSP). + if len(routeHint) > 1 { + group.AdjustedRouteHints = append( + group.AdjustedRouteHints, + routeHint[:len(routeHint)-1], ) } } - return adjustedHints, &refHint, nil -} - -// maxLspFee returns base fee and fee rate amongst all LSP route hints that -// results in the overall highest fee for the given amount. -func maxLspFee(routeHints [][]zpay32.HopHint, amt lnwire.MilliSatoshi) (uint32, - uint32) { - - var maxFeePpm uint32 - var maxBaseFee uint32 - var maxTotalFee lnwire.MilliSatoshi - for _, rh := range routeHints { - lastHop := rh[len(rh)-1] - lastHopFee := lastHop.HopFee(amt) - if lastHopFee > maxTotalFee { - maxTotalFee = lastHopFee - maxBaseFee = lastHop.FeeBaseMSat - maxFeePpm = lastHop.FeeProportionalMillionths - } + if len(lspGroups) == 0 { + return nil, fmt.Errorf("no public LSP nodes found in " + + "route hints") } - return maxBaseFee, maxFeePpm -} + log.Infof("Found %d unique public LSP node(s) in route hints", + len(lspGroups)) -// maxLspCltvDelta returns the maximum cltv delta amongst all LSP route hints. -func maxLspCltvDelta(routeHints [][]zpay32.HopHint) uint16 { - var maxCltvDelta uint16 - for _, rh := range routeHints { - rhLastHop := rh[len(rh)-1] - if rhLastHop.CLTVExpiryDelta > maxCltvDelta { - maxCltvDelta = rhLastHop.CLTVExpiryDelta - } - } - - return maxCltvDelta + return lspGroups, nil } // probePaymentStream is a custom implementation of the grpc.ServerStream diff --git a/lnrpc/routerrpc/router_server_test.go b/lnrpc/routerrpc/router_server_test.go index 477a9b75c..a46a12940 100644 --- a/lnrpc/routerrpc/router_server_test.go +++ b/lnrpc/routerrpc/router_server_test.go @@ -1,12 +1,12 @@ package routerrpc import ( + "bytes" "context" "testing" "time" "github.com/btcsuite/btcd/btcec/v2" - graphdb "github.com/lightningnetwork/lnd/graph/db" "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnwire" paymentsdb "github.com/lightningnetwork/lnd/payments/db" @@ -220,12 +220,18 @@ func TestTrackPaymentsNoInflightUpdates(t *testing.T) { require.Equal(t, lnrpc.Payment_SUCCEEDED, payment.Status) } -// TestIsLsp tests the isLSP heuristic. Combinations of different route hints -// with different fees and cltv deltas are tested to ensure that the heuristic -// correctly identifies whether a route leads to an LSP or not. +// TestIsLsp tests the isLSP heuristic. It validates all three LSP detection +// rules: +// Rule 1: Invoice target is public => not LSP. +// Rule 2: All destination hop hints are private => not LSP (Boltz case). +// Rule 3: At least one destination hop hint is public => LSP (Muun case). func TestIsLsp(t *testing.T) { - probeAmtMsat := lnwire.MilliSatoshi(1_000_000) - + // Setup test nodes: + // - Alice: public node (in graph) + // - Bob: private node + // - Carol: private node + // - Dave: public node (in graph) + // - Eve: private node alicePrivKey, err := btcec.NewPrivateKey() require.NoError(t, err) alicePubKey := alicePrivKey.PubKey() @@ -242,216 +248,519 @@ func TestIsLsp(t *testing.T) { require.NoError(t, err) davePubKey := davePrivKey.PubKey() - var ( - aliceHopHint = zpay32.HopHint{ - NodeID: alicePubKey, - FeeBaseMSat: 100, - FeeProportionalMillionths: 1_000, - ChannelID: 421337, - } + evePrivKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + evePubKey := evePrivKey.PubKey() - bobHopHint = zpay32.HopHint{ - NodeID: bobPubKey, - FeeBaseMSat: 2_000, - FeeProportionalMillionths: 2_000, - CLTVExpiryDelta: 288, - ChannelID: 815, - } + // Create hop hints for each node. + aliceHopHint := zpay32.HopHint{ + NodeID: alicePubKey, + FeeBaseMSat: 100, + FeeProportionalMillionths: 1_000, + CLTVExpiryDelta: 40, + ChannelID: 1, + } - carolHopHint = zpay32.HopHint{ - NodeID: carolPubKey, - FeeBaseMSat: 2_000, - FeeProportionalMillionths: 2_000, - ChannelID: 815, - } + bobHopHint := zpay32.HopHint{ + NodeID: bobPubKey, + FeeBaseMSat: 2_000, + FeeProportionalMillionths: 2_000, + CLTVExpiryDelta: 144, + ChannelID: 2, + } - daveHopHint = zpay32.HopHint{ - NodeID: davePubKey, - FeeBaseMSat: 2_000, - FeeProportionalMillionths: 2_000, - ChannelID: 815, - } + carolHopHint := zpay32.HopHint{ + NodeID: carolPubKey, + FeeBaseMSat: 1_500, + FeeProportionalMillionths: 1_500, + CLTVExpiryDelta: 144, + ChannelID: 3, + } - publicChannelID = uint64(42) - daveHopHintPublicChan = zpay32.HopHint{ - NodeID: davePubKey, - FeeBaseMSat: 2_000, - FeeProportionalMillionths: 2_000, - ChannelID: publicChannelID, - } - ) + daveHopHint := zpay32.HopHint{ + NodeID: davePubKey, + FeeBaseMSat: 3_000, + FeeProportionalMillionths: 3_000, + CLTVExpiryDelta: 288, + ChannelID: 4, + } - bobExpensiveCopy := bobHopHint.Copy() - bobExpensiveCopy.FeeBaseMSat = 1_000_000 - bobExpensiveCopy.FeeProportionalMillionths = 1_000_000 - bobExpensiveCopy.CLTVExpiryDelta = bobHopHint.CLTVExpiryDelta - 1 + eveHopHint := zpay32.HopHint{ + NodeID: evePubKey, + FeeBaseMSat: 500, + FeeProportionalMillionths: 500, + CLTVExpiryDelta: 40, + ChannelID: 5, + } - //nolint:ll - lspTestCases := []struct { - name string - routeHints [][]zpay32.HopHint - probeAmtMsat lnwire.MilliSatoshi - isLsp bool - expectedHints [][]zpay32.HopHint - expectedLspHop *zpay32.HopHint + // Mock hasNode: returns true only for alice and dave. + hasNode := func(nodePub route.Vertex) (bool, error) { + aliceVertex := route.NewVertex(alicePubKey) + daveVertex := route.NewVertex(davePubKey) + return bytes.Equal(nodePub[:], aliceVertex[:]) || + bytes.Equal(nodePub[:], daveVertex[:]), nil + } + + tests := []struct { + name string + routeHints [][]zpay32.HopHint + invoiceTarget []byte + expectLSP bool }{ + // Edge cases. { - name: "empty route hints", - routeHints: [][]zpay32.HopHint{{}}, - probeAmtMsat: probeAmtMsat, - isLsp: false, - expectedHints: [][]zpay32.HopHint{}, - expectedLspHop: nil, + name: "no route hints", + routeHints: [][]zpay32.HopHint{}, + invoiceTarget: nil, + expectLSP: false, }, { - name: "single route hint", - routeHints: [][]zpay32.HopHint{{daveHopHint}}, - probeAmtMsat: probeAmtMsat, - isLsp: true, - expectedHints: [][]zpay32.HopHint{}, - expectedLspHop: &daveHopHint, + name: "empty route hint array", + routeHints: [][]zpay32.HopHint{{}}, + invoiceTarget: nil, + expectLSP: false, }, + + // Rule 1: Invoice target is public => NOT an LSP. + // Rationale: Can route directly to public target. { - name: "single route, multiple hints", - routeHints: [][]zpay32.HopHint{{ - aliceHopHint, bobHopHint, - }}, - probeAmtMsat: probeAmtMsat, - isLsp: true, - expectedHints: [][]zpay32.HopHint{{aliceHopHint}}, - expectedLspHop: &bobHopHint, - }, - { - name: "multiple routes, multiple hints", + name: "invoice target is public (alice)", routeHints: [][]zpay32.HopHint{ - { - aliceHopHint, bobHopHint, - }, - { - carolHopHint, bobHopHint, - }, + {bobHopHint, carolHopHint}, }, - probeAmtMsat: probeAmtMsat, - isLsp: true, - expectedHints: [][]zpay32.HopHint{ - {aliceHopHint}, {carolHopHint}, - }, - expectedLspHop: &bobHopHint, + invoiceTarget: alicePubKey.SerializeCompressed(), + expectLSP: false, }, { - name: "multiple routes, multiple hints with min length", + name: "invoice target is public with public dest hop", routeHints: [][]zpay32.HopHint{ - { - bobHopHint, - }, - { - carolHopHint, bobHopHint, - }, + {bobHopHint, daveHopHint}, }, - probeAmtMsat: probeAmtMsat, - isLsp: true, - expectedHints: [][]zpay32.HopHint{ - {carolHopHint}, - }, - expectedLspHop: &bobHopHint, + invoiceTarget: davePubKey.SerializeCompressed(), + expectLSP: false, }, { - name: "multiple routes, multiple hints, diff fees+cltv", + name: "invoice target is public with multiple routes", routeHints: [][]zpay32.HopHint{ - { - bobHopHint, - }, - { - carolHopHint, bobExpensiveCopy, - }, + {bobHopHint, carolHopHint}, + {aliceHopHint, daveHopHint}, }, - probeAmtMsat: probeAmtMsat, - isLsp: true, - expectedHints: [][]zpay32.HopHint{ - {carolHopHint}, - }, - expectedLspHop: &zpay32.HopHint{ - NodeID: bobHopHint.NodeID, - ChannelID: bobHopHint.ChannelID, - FeeBaseMSat: bobExpensiveCopy.FeeBaseMSat, - FeeProportionalMillionths: bobExpensiveCopy.FeeProportionalMillionths, - CLTVExpiryDelta: bobHopHint.CLTVExpiryDelta, + invoiceTarget: alicePubKey.SerializeCompressed(), + expectLSP: false, + }, + + // Rule 2: All destination hop hints are private => NOT an LSP. + // Rationale: The destination hop hint is private so it cannot + // be probed so we default to NOT an LSP. + { + name: "single route to private dest", + routeHints: [][]zpay32.HopHint{ + {aliceHopHint, bobHopHint}, }, + invoiceTarget: bobPubKey.SerializeCompressed(), + expectLSP: false, }, { - name: "multiple routes, different final hops", + name: "multiple routes, all to private dests", routeHints: [][]zpay32.HopHint{ - { - aliceHopHint, bobHopHint, - }, - { - carolHopHint, daveHopHint, - }, + {aliceHopHint, bobHopHint}, + {daveHopHint, carolHopHint}, }, - probeAmtMsat: probeAmtMsat, - isLsp: false, - expectedHints: [][]zpay32.HopHint{}, - expectedLspHop: nil, + invoiceTarget: nil, + expectLSP: false, }, { - name: "multiple routes, same public hops", + name: "single hop to private node", routeHints: [][]zpay32.HopHint{ - { - aliceHopHint, daveHopHintPublicChan, - }, - { - carolHopHint, daveHopHintPublicChan, - }, + {eveHopHint}, }, - probeAmtMsat: probeAmtMsat, - isLsp: false, - expectedHints: [][]zpay32.HopHint{}, - expectedLspHop: nil, + invoiceTarget: evePubKey.SerializeCompressed(), + expectLSP: false, }, { - name: "multiple routes, same public hops", + name: "all routes to same private node", routeHints: [][]zpay32.HopHint{ - { - aliceHopHint, daveHopHint, - }, - { - carolHopHint, daveHopHintPublicChan, - }, - { - aliceHopHint, daveHopHintPublicChan, - }, + {aliceHopHint, bobHopHint}, + {daveHopHint, bobHopHint}, + {carolHopHint, bobHopHint}, }, - probeAmtMsat: probeAmtMsat, - isLsp: false, - expectedHints: [][]zpay32.HopHint{}, - expectedLspHop: nil, + invoiceTarget: nil, + expectLSP: false, + }, + + // Rule 3: At least one destination hop is public => IS an LSP. + // Rationale: As long as there is at least one public + // destination route hint, it is an LSP setup and can be probed. + { + name: "single route to public dest (dave)", + routeHints: [][]zpay32.HopHint{ + {bobHopHint, daveHopHint}, + }, + invoiceTarget: evePubKey.SerializeCompressed(), + expectLSP: true, + }, + { + name: "direct hop to public LSP (alice)", + routeHints: [][]zpay32.HopHint{ + {aliceHopHint}, + }, + invoiceTarget: bobPubKey.SerializeCompressed(), + expectLSP: true, + }, + { + name: "multiple routes to same public LSP (dave)", + routeHints: [][]zpay32.HopHint{ + {bobHopHint, daveHopHint}, + {carolHopHint, daveHopHint}, + {eveHopHint, daveHopHint}, + }, + invoiceTarget: nil, + expectLSP: true, + }, + { + name: "multiple routes to different public LSPs", + routeHints: [][]zpay32.HopHint{ + {bobHopHint, aliceHopHint}, + {carolHopHint, daveHopHint}, + }, + invoiceTarget: nil, + expectLSP: true, + }, + { + name: "mixed public and private dest hops", + routeHints: [][]zpay32.HopHint{ + {aliceHopHint, bobHopHint}, + {carolHopHint, daveHopHint}, + {bobHopHint, eveHopHint}, + }, + invoiceTarget: nil, + expectLSP: true, + }, + { + name: "first route has public dest, rest private", + routeHints: [][]zpay32.HopHint{ + {bobHopHint, aliceHopHint}, + {carolHopHint, eveHopHint}, + }, + invoiceTarget: nil, + expectLSP: true, }, } - // Returns ErrEdgeNotFound for private channels. - fetchChannelEndpoints := func(chanID uint64) (route.Vertex, - route.Vertex, error) { - - if chanID == publicChannelID { - return route.Vertex{}, route.Vertex{}, nil - } - - return route.Vertex{}, route.Vertex{}, graphdb.ErrEdgeNotFound - } - - for _, tc := range lspTestCases { - t.Run(tc.name, func(t *testing.T) { - isLsp := isLSP(tc.routeHints, fetchChannelEndpoints) - require.Equal(t, tc.isLsp, isLsp) - if !tc.isLsp { - return - } - - adjustedHints, lspHint, _ := prepareLspRouteHints( - tc.routeHints, tc.probeAmtMsat, + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isLSP( + tt.routeHints, tt.invoiceTarget, hasNode, ) - require.Equal(t, tc.expectedHints, adjustedHints) - require.Equal(t, tc.expectedLspHop, lspHint) + require.Equal(t, tt.expectLSP, result) }) } } + +// TestPrepareLspRouteHints tests the prepareLspRouteHints function to ensure +// it correctly filters, groups, and calculates worst-case fees for LSP routes. +func TestPrepareLspRouteHints(t *testing.T) { + // Setup test nodes: + // - Alice: public LSP node (in graph) + // - Bob: private node + // - Carol: private node + // - Dave: public LSP node (in graph) + // - Eve: private node + alicePrivKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + alicePubKey := alicePrivKey.PubKey() + + bobPrivKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + bobPubKey := bobPrivKey.PubKey() + + carolPrivKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + carolPubKey := carolPrivKey.PubKey() + + davePrivKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + davePubKey := davePrivKey.PubKey() + + evePrivKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + evePubKey := evePrivKey.PubKey() + + // Create hop hints with varying fees and CLTV deltas. + aliceHopHint1 := zpay32.HopHint{ + NodeID: alicePubKey, + FeeBaseMSat: 100, + FeeProportionalMillionths: 1_000, + CLTVExpiryDelta: 40, + ChannelID: 1, + } + + aliceHopHint2 := zpay32.HopHint{ + NodeID: alicePubKey, + FeeBaseMSat: 200, + FeeProportionalMillionths: 2_000, + CLTVExpiryDelta: 80, + ChannelID: 2, + } + + bobHopHint := zpay32.HopHint{ + NodeID: bobPubKey, + FeeBaseMSat: 500, + FeeProportionalMillionths: 500, + CLTVExpiryDelta: 144, + ChannelID: 3, + } + + carolHopHint := zpay32.HopHint{ + NodeID: carolPubKey, + FeeBaseMSat: 300, + FeeProportionalMillionths: 300, + CLTVExpiryDelta: 40, + ChannelID: 4, + } + + daveHopHint1 := zpay32.HopHint{ + NodeID: davePubKey, + FeeBaseMSat: 1_000, + FeeProportionalMillionths: 1_000, + CLTVExpiryDelta: 144, + ChannelID: 5, + } + + daveHopHint2 := zpay32.HopHint{ + NodeID: davePubKey, + FeeBaseMSat: 2_000, + FeeProportionalMillionths: 500, + CLTVExpiryDelta: 288, + ChannelID: 6, + } + + eveHopHint := zpay32.HopHint{ + NodeID: evePubKey, + FeeBaseMSat: 100, + FeeProportionalMillionths: 100, + CLTVExpiryDelta: 40, + ChannelID: 7, + } + + // Mock hasNode: returns true only for alice and dave. + hasNode := func(nodePub route.Vertex) (bool, error) { + aliceVertex := route.NewVertex(alicePubKey) + daveVertex := route.NewVertex(davePubKey) + return bytes.Equal(nodePub[:], aliceVertex[:]) || + bytes.Equal(nodePub[:], daveVertex[:]), nil + } + + amt := lnwire.MilliSatoshi(1_000_000) + + tests := []struct { + name string + routeHints [][]zpay32.HopHint + expectedGrps int + validateFunc func(t *testing.T, + groups map[route.Vertex]*LspRouteGroup) + }{ + { + name: "single public LSP with one route", + routeHints: [][]zpay32.HopHint{ + {bobHopHint, aliceHopHint1}, + }, + expectedGrps: 1, + validateFunc: func(t *testing.T, + groups map[route.Vertex]*LspRouteGroup) { + + require.Len(t, groups, 1) + + // Find alice's group. + aliceKey := route.NewVertex(alicePubKey) + group, ok := groups[aliceKey] + require.True(t, ok, "alice group not found") + + // Verify LSP hop hint. + require.Equal(t, aliceHopHint1.FeeBaseMSat, + group.LspHopHint.FeeBaseMSat) + require.Equal(t, aliceHopHint1.CLTVExpiryDelta, + group.LspHopHint.CLTVExpiryDelta) + + // Verify adjusted route hints. + require.Len(t, group.AdjustedRouteHints, 1) + require.Len(t, group.AdjustedRouteHints[0], 1) + require.Equal(t, bobHopHint.NodeID, + group.AdjustedRouteHints[0][0].NodeID) + }, + }, + { + name: "single LSP with multiple routes, same fees", + routeHints: [][]zpay32.HopHint{ + {bobHopHint, aliceHopHint1}, + {carolHopHint, aliceHopHint1}, + }, + expectedGrps: 1, + validateFunc: func(t *testing.T, + groups map[route.Vertex]*LspRouteGroup) { + + aliceKey := route.NewVertex(alicePubKey) + group, ok := groups[aliceKey] + require.True(t, ok, "alice group not found") + + // Should have 2 adjusted route hints. + require.Len(t, group.AdjustedRouteHints, 2) + + // Fees should match the single hop hint. + require.Equal(t, aliceHopHint1.FeeBaseMSat, + group.LspHopHint.FeeBaseMSat) + require.Equal(t, aliceHopHint1.CLTVExpiryDelta, + group.LspHopHint.CLTVExpiryDelta) + }, + }, + { + name: "single LSP with different fees, uses worst case", + routeHints: [][]zpay32.HopHint{ + {bobHopHint, aliceHopHint1}, + {carolHopHint, aliceHopHint2}, + }, + expectedGrps: 1, + validateFunc: func(t *testing.T, + groups map[route.Vertex]*LspRouteGroup) { + + aliceKey := route.NewVertex(alicePubKey) + group, ok := groups[aliceKey] + require.True(t, ok, "alice group not found") + + // Should use worst-case (higher) fees. + fee1 := aliceHopHint1.HopFee(amt) + fee2 := aliceHopHint2.HopFee(amt) + require.Greater(t, fee2, fee1, + "hint2 should have higher fees") + + // Group should have hint2's fees. + require.Equal(t, aliceHopHint2.FeeBaseMSat, + group.LspHopHint.FeeBaseMSat) + + //nolint:ll + require.Equal(t, + aliceHopHint2.FeeProportionalMillionths, + group.LspHopHint.FeeProportionalMillionths) + + // Should use worst-case CLTV delta. + require.Equal(t, aliceHopHint2.CLTVExpiryDelta, + group.LspHopHint.CLTVExpiryDelta) + }, + }, + { + name: "multiple public LSPs", + routeHints: [][]zpay32.HopHint{ + {bobHopHint, aliceHopHint1}, + {carolHopHint, daveHopHint1}, + }, + expectedGrps: 2, + validateFunc: func(t *testing.T, + groups map[route.Vertex]*LspRouteGroup) { + + require.Len(t, groups, 2) + + aliceKey := route.NewVertex(alicePubKey) + daveKey := route.NewVertex(davePubKey) + + _, hasAlice := groups[aliceKey] + _, hasDave := groups[daveKey] + require.True(t, hasAlice, "alice group missing") + require.True(t, hasDave, "dave group missing") + }, + }, + { + name: "filters out private dest hops", + routeHints: [][]zpay32.HopHint{ + {aliceHopHint1, bobHopHint}, + {carolHopHint, daveHopHint1}, + {bobHopHint, eveHopHint}, + }, + expectedGrps: 1, + validateFunc: func(t *testing.T, + groups map[route.Vertex]*LspRouteGroup) { + + require.Len(t, groups, 1) + + daveKey := route.NewVertex(davePubKey) + group, ok := groups[daveKey] + require.True(t, ok, "dave group not found") + + // Only one route hint should remain + require.Len(t, group.AdjustedRouteHints, 1) + }, + }, + { + name: "multiple routes to same LSP with varying CLTV", + routeHints: [][]zpay32.HopHint{ + {bobHopHint, daveHopHint1}, + {carolHopHint, daveHopHint2}, + }, + expectedGrps: 1, + validateFunc: func(t *testing.T, + groups map[route.Vertex]*LspRouteGroup) { + + daveKey := route.NewVertex(davePubKey) + group, ok := groups[daveKey] + require.True(t, ok, "dave group not found") + + // Should use maximum CLTV delta. + require.Equal(t, daveHopHint2.CLTVExpiryDelta, + group.LspHopHint.CLTVExpiryDelta) + }, + }, + { + name: "single hop to public LSP", + routeHints: [][]zpay32.HopHint{ + {aliceHopHint1}, + }, + expectedGrps: 1, + validateFunc: func(t *testing.T, + groups map[route.Vertex]*LspRouteGroup) { + + aliceKey := route.NewVertex(alicePubKey) + group, ok := groups[aliceKey] + require.True(t, ok, "alice group not found") + + // No adjusted hints since it's a direct hop + require.Len(t, group.AdjustedRouteHints, 0) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + groups, err := prepareLspRouteHints( + tt.routeHints, amt, hasNode, + ) + require.NoError(t, err) + require.Len(t, groups, tt.expectedGrps) + + // Run custom validation if provided. + if tt.validateFunc != nil { + tt.validateFunc(t, groups) + } + }) + } + + // Error cases which in operation should never happen because we always + // call isLSP first to check if the route hints are an LSP setup. + t.Run("error: no route hints", func(t *testing.T) { + _, err := prepareLspRouteHints( + [][]zpay32.HopHint{}, amt, hasNode, + ) + require.Error(t, err) + require.Contains(t, err.Error(), "no route hints") + }) + + t.Run("error: no public LSP nodes found", func(t *testing.T) { + // All private destination hops. If all destination hops are + // private we cannot probe any LSPs so we return an error. + routeHints := [][]zpay32.HopHint{ + {aliceHopHint1, bobHopHint}, + {daveHopHint1, carolHopHint}, + } + _, err := prepareLspRouteHints(routeHints, amt, hasNode) + require.Error(t, err) + require.Contains(t, err.Error(), "no public LSP nodes found") + }) +} From 6af171f3657104bfdd48d641add86adb8332f5da Mon Sep 17 00:00:00 2001 From: ziggie Date: Sat, 29 Nov 2025 00:11:05 +0100 Subject: [PATCH 037/134] itest: enhance testEstimateRouteFee with multi-LSP scenarios This commit enhances the integration test to validate the LSP heuristic end-to-end with real network topology and payment probing. Network topology additions: - Added Frank node as a private destination - Created multi-LSP test scenario with Bob, Eve, and Dave as LSPs New test cases: 1. "probe based estimate, public target with public hop hints" - Validates Rule 1: public invoice target routes directly - Even with public hop hints, direct routing is used - Expected: standard single-hop fees 2. "probe based estimate, multiple different public LSPs" - Validates multi-LSP worst-case selection - Frank has routes through Bob (low fee), Eve (HIGH fee), Dave (medium) - Expected: Eve's worst-case fees (most expensive) - Tests griefing protection (max 3 LSP probes) --- itest/lnd_estimate_route_fee_test.go | 107 ++++++++++++++++++++++++++- 1 file changed, 104 insertions(+), 3 deletions(-) diff --git a/itest/lnd_estimate_route_fee_test.go b/itest/lnd_estimate_route_fee_test.go index 713cfe1ed..07329d969 100644 --- a/itest/lnd_estimate_route_fee_test.go +++ b/itest/lnd_estimate_route_fee_test.go @@ -59,18 +59,38 @@ type estimateRouteFeeTestCase struct { } // testEstimateRouteFee tests the estimation of routing fees using either graph -// data or sending out a probe payment. +// data or sending out a probe payment. This test validates graph-based fee +// estimation, probe-based fee estimation with single LSP, probe-based fee +// estimation with multiple route hints to same LSP (worst-case fee selection), +// probe-based fee estimation with multiple different public LSPs (worst-case +// fee selection across LSPs, up to MaxLspsToProbe), and non-LSP probing (all +// private destination hops). +// +// Note: We test with exactly MaxLspsToProbe (3) LSPs. Testing with more LSPs +// is not feasible because the LSP selection uses map iteration, which has +// non-deterministic order in Go, making it impossible to predict which LSPs +// will be probed. func testEstimateRouteFee(ht *lntest.HarnessTest) { + // Ensure MaxLspsToProbe is set to 3 as expected by this test. The test + // uses exactly 3 LSPs in the multi-LSP test case. If MaxLspsToProbe + // changes, this assertion will fail as a reminder to update the test. + require.Equal(ht, 3, routerrpc.MaxLspsToProbe, + "MaxLspsToProbe should be 3") + mts := newMppTestScenario(ht) - // We extend the regular mpp test scenario with a new node Paula. Paula - // is connected to Bob and Eve through private channels. + // We extend the regular mpp test scenario with two new nodes: + // - Paula: connected to Bob and Eve through private channels + // - Frank: connected to Dave through a private channel + // // /-------------\ // _ Eve _ (private) \ // / \ \ // Alice -- Carol ---- Bob --------- Paula // \ / (private) // \__ Dave ____/ + // \ + // \__ Frank (private) // req := &mppOpenChannelRequest{ amtAliceCarol: 200_000, @@ -88,6 +108,7 @@ func testEstimateRouteFee(ht *lntest.HarnessTest) { probeInitiator = mts.alice paula := ht.NewNode("Paula", nil) + frank := ht.NewNode("Frank", nil) // The channel from Bob to Paula actually doesn't have enough liquidity // to carry out the probe. We assume in normal operation that hop hints @@ -106,6 +127,13 @@ func testEstimateRouteFee(ht *lntest.HarnessTest) { Amt: 1_000_000, }) + // Frank is a private node connected to Dave (public LSP). + ht.EnsureConnected(mts.dave, frank) + ht.OpenChannel(mts.dave, frank, lntest.OpenChannelParams{ + Private: true, + Amt: 1_000_000, + }) + bobsPrivChannels := mts.bob.RPC.ListChannels(&lnrpc.ListChannelsRequest{ PrivateOnly: true, }) @@ -118,6 +146,14 @@ func testEstimateRouteFee(ht *lntest.HarnessTest) { require.Len(ht, evesPrivChannels.Channels, 1) evePaulaChanID := evesPrivChannels.Channels[0].ChanId + davesPrivChannels := mts.dave.RPC.ListChannels( + &lnrpc.ListChannelsRequest{ + PrivateOnly: true, + }, + ) + require.Len(ht, davesPrivChannels.Channels, 1) + daveFrankChanID := davesPrivChannels.Channels[0].ChanId + // Let's disable the paths from Alice to Bob through Dave and Eve with // high fees. This ensures that the path estimates are based on Carol's // channel to Bob for the first set of tests. @@ -196,6 +232,33 @@ func testEstimateRouteFee(ht *lntest.HarnessTest) { }, }, } + + daveHopHint = &lnrpc.HopHint{ + NodeId: mts.dave.PubKeyStr, + FeeBaseMsat: 3_000, + FeeProportionalMillionths: 3_000, + CltvExpiryDelta: 120, + ChanId: daveFrankChanID, + } + + // Multiple different public LSPs (Bob, Eve, Dave). + multipleLspsRouteHints = []*lnrpc.RouteHint{ + { + HopHints: []*lnrpc.HopHint{ + bobHopHint, + }, + }, + { + HopHints: []*lnrpc.HopHint{ + eveHopHint, + }, + }, + { + HopHints: []*lnrpc.HopHint{ + daveHopHint, + }, + }, + } ) defaultTimelock := int64(chainreg.DefaultBitcoinTimeLockDelta) @@ -231,6 +294,14 @@ func testEstimateRouteFee(ht *lntest.HarnessTest) { feeACEP := feeEP + feeCE deltaACEP := deltaCE + deltaEP + // For multiple LSPs test, the route with the highest fee should be + // selected (Eve). Note that we return both fee and CLTV delta from + // the same route (the highest-fee route), not the max fee and max + // delta independently. This ensures the returned values represent an + // actual viable route. + highestFeeRouteFee := feeACEP + highestFeeRouteDelta := deltaACEP + initialBlockHeight := int64(mts.alice.RPC.GetInfo().BlockHeight) // Locktime is always composed of the initial block height and the @@ -271,6 +342,19 @@ func testEstimateRouteFee(ht *lntest.HarnessTest) { expectedCltvDelta: locktime + deltaCB, expectedFailureReason: failureReasonNone, }, + // Rule 1: Invoice target is public (Bob), even with public + // destination hop hints. Should route directly to Bob, NOT + // treat as LSP. + { + name: "probe based estimate, public " + + "target with public hop hints", + probing: true, + destination: mts.bob, + routeHints: singleRouteHint, + expectedRoutingFeesMsat: feeStandardSingleHop, + expectedCltvDelta: locktime + deltaCB, + expectedFailureReason: failureReasonNone, + }, // We expect the previous probing results adjusted by Paula's // hop data. { @@ -340,6 +424,23 @@ func testEstimateRouteFee(ht *lntest.HarnessTest) { expectedCltvDelta: 0, expectedFailureReason: failureReasonNoRoute, }, + // Test multiple different public LSPs. The worst-case (most + // expensive) route should be returned. Eve has the highest + // fees among the 3 LSPs tested. Note: We don't test with more + // than MaxLspsToProbe LSPs because map iteration order in Go + // is non-deterministic, making it impossible to predict which + // LSPs will be selected for probing. + { + name: "probe based estimate, " + + "multiple different public LSPs", + probing: true, + destination: frank, + routeHints: multipleLspsRouteHints, + expectedRoutingFeesMsat: highestFeeRouteFee, + expectedCltvDelta: locktime + + highestFeeRouteDelta, + expectedFailureReason: failureReasonNone, + }, } for _, testCase := range testCases { From 68c4809913ad44f167f919e2391cdcae64d4adad Mon Sep 17 00:00:00 2001 From: ziggie Date: Sun, 30 Nov 2025 09:05:30 +0100 Subject: [PATCH 038/134] docs: update api documentation for estimateRouteFee --- docs/estimate_route_fee.md | 72 ++++++++++++++++++++------------------ 1 file changed, 37 insertions(+), 35 deletions(-) diff --git a/docs/estimate_route_fee.md b/docs/estimate_route_fee.md index 8a4fc8047..7b74efa0a 100644 --- a/docs/estimate_route_fee.md +++ b/docs/estimate_route_fee.md @@ -150,52 +150,58 @@ probing. The heuristic examines the structure of route hints provided in the invoice to identify characteristic LSP patterns. The detection operates on the principle -that LSPs typically maintain private channels to their users and appear as the -penultimate hop in payment routing. +that LSPs typically maintain private channels to their users and appear as +public nodes in the network, while the final destination is private. ```mermaid flowchart TD Start([Route Hints Received]) --> Empty{Empty Hints?} Empty -->|Yes| NotLSP([Not LSP]) - Empty -->|No| GetFirst[Get First Hint's Last Hop] - - GetFirst --> CheckPub1{Is Channel
Public?} - CheckPub1 -->|Yes| NotLSP - CheckPub1 -->|No| SaveNode[Save Node ID] - - SaveNode --> MoreHints{More Hints?} - MoreHints -->|No| IsLSP([Detected as LSP]) + Empty -->|No| CheckTarget{Invoice Target
in Graph?} + + CheckTarget -->|Yes| NotLSP + CheckTarget -->|No| GetFirstDest[Get First Hint's
Destination Hop] + + GetFirstDest --> CheckPub1{Destination Node
in Graph?} + CheckPub1 -->|Yes| IsLSP([Detected as LSP]) + CheckPub1 -->|No| MoreHints{More Hints?} + + MoreHints -->|No| NotLSP MoreHints -->|Yes| NextHint[Check Next Hint] - - NextHint --> GetLast[Get Last Hop] - GetLast --> CheckPub2{Is Channel
Public?} - CheckPub2 -->|Yes| NotLSP - CheckPub2 -->|No| SameNode{Same Node ID
as First?} - - SameNode -->|No| NotLSP - SameNode -->|Yes| MoreHints + + NextHint --> GetNextDest[Get Destination Hop] + GetNextDest --> CheckPub2{Destination Node
in Graph?} + CheckPub2 -->|Yes| IsLSP + CheckPub2 -->|No| MoreHints ``` -The detection criteria are: +The detection follows three simple rules applied sequentially: -- **All route hints must terminate at the same node ID** - This indicates a - single destination behind potentially multiple LSP entry points +**Rule 1: Public Invoice Target → NOT an LSP** +- If the invoice target (destination) is a public node that exists in the + channel graph, the payment can be routed directly to it +- This means it's not an LSP setup, regardless of what route hints are provided +- Example: A well-connected merchant node with route hints for liquidity + signaling -- **Final hop channels must be private** - The channels in the last hop of - each route hint must not exist in the public channel graph +**Rule 2: Public Destination Hop → IS an LSP** +- If at least one route hint has a destination hop (last hop in the route hint) + that is a public node in the graph, LSP detection is triggered +- This indicates the destination hop is an LSP serving a private client +- The private client is reached through the LSP's private channel -- **No public channels in final hops** - If any route hint contains a public - channel in its final hop, LSP detection is disabled entirely - -- **Multiple route hints strengthen detection** - While not required, - multiple hints converging on the same destination strongly suggest an LSP - configuration +**Rule 3: All Private Destination Hops → NOT an LSP** +- If all destination hops in all route hints are private nodes (not in the + public graph), this is not treated as an LSP setup +- The payment will be routed directly to the invoice destination using the + route hints as additional path information +- This is the standard case for private channel payments This pattern effectively distinguishes LSP configurations from other routing scenarios. For instance, some Lightning implementations like CLN include route hints even for public nodes to signal liquidity availability or preferred -routing paths. The heuristic correctly identifies these as non-LSP scenarios by -detecting the presence of public channels. +routing paths. The heuristic correctly identifies these as non-LSP scenarios +by Rule 1 (detecting that the invoice target itself is public). ### How Probing Differs When an LSP is Detected @@ -432,10 +438,6 @@ appropriately. The `EstimateRouteFee` implementation continues to evolve based on real-world usage patterns. Ongoing discussions in the LND community focus on: -**Improved LSP Detection**: Developing more sophisticated heuristics that -accurately identify LSP configurations while avoiding false positives for -regular private channels. - **Multi-Path Payment Support**: Extending fee estimation to support MPP scenarios where payments split across multiple routes. From 43091bdd51df4c9af13b1aad85032a1775a9b88a Mon Sep 17 00:00:00 2001 From: ziggie Date: Sat, 29 Nov 2025 01:09:54 +0100 Subject: [PATCH 039/134] docs: add release-notes for LND 20.1 --- docs/release-notes/release-notes-0.20.1.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/release-notes/release-notes-0.20.1.md b/docs/release-notes/release-notes-0.20.1.md index b1c00de95..b679958c8 100644 --- a/docs/release-notes/release-notes-0.20.1.md +++ b/docs/release-notes/release-notes-0.20.1.md @@ -72,6 +72,14 @@ ## RPC Updates + * The `EstimateRouteFee` RPC now implements an [LSP detection + heuristic](https://github.com/lightningnetwork/lnd/pull/10396) that probes up + to 3 unique Lightning Service Providers when route hints indicate an LSP + setup. The implementation returns worst-case (most expensive) fee estimates + for conservative budgeting and includes griefing protection by limiting the + number of probed LSPs. It enhances the previous LSP design by being more + generic and more flexible. + ## lncli Updates ## Breaking Changes From 1a543fbfcd328059bc054d9a78362e5e7bf51c91 Mon Sep 17 00:00:00 2001 From: ziggie Date: Wed, 10 Dec 2025 09:35:08 +0100 Subject: [PATCH 040/134] routerrpc: fix payment address deep copy using copy for a slice of size 0 will not copy anything so we need to first initialize the slice before we do the deep copy. (cherry picked from commit 1c4bcc3b7dba14ed52c69f4b7041bd431d528c49) --- lnrpc/routerrpc/router_server.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lnrpc/routerrpc/router_server.go b/lnrpc/routerrpc/router_server.go index db04526d4..27cd85824 100644 --- a/lnrpc/routerrpc/router_server.go +++ b/lnrpc/routerrpc/router_server.go @@ -557,6 +557,7 @@ func (s *Server) probePaymentRequest(ctx context.Context, paymentRequest string, // If the payment addresses is specified, then we'll also populate that // now as well. payReq.PaymentAddr.WhenSome(func(addr [32]byte) { + probeRequest.PaymentAddr = make([]byte, lntypes.HashSize) copy(probeRequest.PaymentAddr, addr[:]) }) @@ -624,6 +625,10 @@ func (s *Server) probePaymentRequest(ctx context.Context, paymentRequest string, // Copy the payment address if present. if len(probeRequest.PaymentAddr) > 0 { + lspProbeRequest.PaymentAddr = make( + []byte, lntypes.HashSize, + ) + copy( lspProbeRequest.PaymentAddr, probeRequest.PaymentAddr, From f82c35c77741835bb2df5b82d9663c6779276e6c Mon Sep 17 00:00:00 2001 From: ziggie Date: Wed, 10 Dec 2025 09:39:43 +0100 Subject: [PATCH 041/134] docs: add release-notes for LND 20.1 (cherry picked from commit ac30443cc19b0213912bc704462ddf5af7feae50) --- docs/release-notes/release-notes-0.20.1.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/release-notes/release-notes-0.20.1.md b/docs/release-notes/release-notes-0.20.1.md index b679958c8..ee76c6f4d 100644 --- a/docs/release-notes/release-notes-0.20.1.md +++ b/docs/release-notes/release-notes-0.20.1.md @@ -59,6 +59,11 @@ issue](https://github.com/lightningnetwork/lnd/pull/10428) in LND which might happen when running postgres with a limited number of connections configured. +* [Add missing payment address/secret when probing an + invoice](https://github.com/lightningnetwork/lnd/pull/10439). This makes sure + the EstimateRouteFee API can probe Eclair and LDK nodes which enforce the + payment address/secret. + # New Features ## Functional Enhancements From 677ffabed8a8b9aee6de5049e8d9df7e8971b170 Mon Sep 17 00:00:00 2001 From: ziggie Date: Mon, 15 Dec 2025 14:07:40 +0100 Subject: [PATCH 042/134] server: fix timestamp comparison in setSelfNode Fix bug where setSelfNode compared only the seconds component of timestamps instead of the full timestamp. This caused the node to attempt persisting an older timestamp than what existed in the database during restart, resulting in "sql: no rows in result set" errors. (cherry picked from commit 865e1556d46168d47f3a5745e07c873bc09a78df) --- server.go | 20 +++++-- server_test.go | 141 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 server_test.go diff --git a/server.go b/server.go index b9b6bba3f..4c141a321 100644 --- a/server.go +++ b/server.go @@ -5497,6 +5497,20 @@ func (s *server) AttemptRBFCloseUpdate(ctx context.Context, return updates, nil } +// calculateNodeAnnouncementTimestamp returns the timestamp to use for a node +// announcement, ensuring it's at least one second after the previously +// persisted timestamp. This ensures BOLT-07 compliance, which requires node +// announcements to have strictly increasing timestamps. +func calculateNodeAnnouncementTimestamp(persistedTime, + currentTime time.Time) time.Time { + + if persistedTime.Unix() >= currentTime.Unix() { + return persistedTime.Add(time.Second) + } + + return currentTime +} + // setSelfNode configures and sets the server's self node. It sets the node // announcement, signs it, and updates the source node in the graph. When // determining values such as color and alias, the method prioritizes values @@ -5564,9 +5578,9 @@ func (s *server) setSelfNode(ctx context.Context, nodePub route.Vertex, // If we have a source node persisted in the DB already, then we // just need to make sure that the new LastUpdate time is at // least one second after the last update time. - if srcNode.LastUpdate.Second() >= nodeLastUpdate.Second() { - nodeLastUpdate = srcNode.LastUpdate.Add(time.Second) - } + nodeLastUpdate = calculateNodeAnnouncementTimestamp( + srcNode.LastUpdate, nodeLastUpdate, + ) // If the color is not changed from default, it means that we // didn't specify a different color in the config. We'll use the diff --git a/server_test.go b/server_test.go new file mode 100644 index 000000000..0cb364318 --- /dev/null +++ b/server_test.go @@ -0,0 +1,141 @@ +package lnd + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// TestNodeAnnouncementTimestampComparison tests the timestamp comparison +// logic used in setSelfNode to ensure node announcements have strictly +// increasing timestamps at second precision (as required by BOLT-07 and +// enforced by the database storage). +func TestNodeAnnouncementTimestampComparison(t *testing.T) { + t.Parallel() + + // Use a simple base time for the tests. + baseTime := int64(1000) + + tests := []struct { + name string + srcNodeLastUpdate time.Time + nodeLastUpdate time.Time + expectedResult time.Time + description string + }{ + { + name: "same second different nanoseconds", + srcNodeLastUpdate: time.Unix(baseTime, 0), + nodeLastUpdate: time.Unix(baseTime, 500_000_000), + expectedResult: time.Unix(baseTime+1, 0), + description: "Edge case: timestamps in same second " + + "but different nanoseconds. Must increment " + + "to avoid persisting same second-level " + + "timestamp.", + }, + { + name: "different seconds", + srcNodeLastUpdate: time.Unix(baseTime, 0), + nodeLastUpdate: time.Unix(baseTime+2, 0), + expectedResult: time.Unix(baseTime+2, 0), + description: "Normal case: current time is already " + + "in a different (later) second. No increment " + + "needed.", + }, + { + name: "exactly equal", + srcNodeLastUpdate: time.Unix(baseTime, 123456789), + nodeLastUpdate: time.Unix(baseTime, 123456789), + expectedResult: time.Unix(baseTime+1, 123456789), + description: "Timestamps are identical. Must " + + "increment to ensure strictly greater " + + "timestamp.", + }, + { + name: "exactly equal - zero nanoseconds", + srcNodeLastUpdate: time.Unix(baseTime, 0), + nodeLastUpdate: time.Unix(baseTime, 0), + expectedResult: time.Unix(baseTime+1, 0), + description: "Timestamps are identical at second " + + "precision (0 nanoseconds), as would be read " + + "from DB. Must increment.", + }, + { + name: "clock skew - persisted is newer", + srcNodeLastUpdate: time.Unix(baseTime+5, 0), + nodeLastUpdate: time.Unix(baseTime+3, 0), + expectedResult: time.Unix(baseTime+6, 0), + description: "Clock went backwards: persisted " + + "timestamp is newer than current time. Must " + + "increment from persisted timestamp.", + }, + { + name: "clock skew - same second", + srcNodeLastUpdate: time.Unix(baseTime+5, 100_000_000), + nodeLastUpdate: time.Unix(baseTime+5, 900_000_000), + expectedResult: time.Unix(baseTime+6, 100_000_000), + description: "Clock skew within same second. Must " + + "increment to ensure strictly greater " + + "second-level timestamp.", + }, + { + name: "same second component different " + + "minute", + srcNodeLastUpdate: time.Unix(baseTime, 0), + nodeLastUpdate: time.Unix(baseTime+60, 0), + expectedResult: time.Unix(baseTime+60, 0), + description: "Same seconds component (:00) but " + + "different minutes. Current time is later. " + + "Verifies we use .Unix() not .Second().", + }, + { + name: "lower second component but " + + "later time", + srcNodeLastUpdate: time.Unix(baseTime+58, 0), + nodeLastUpdate: time.Unix(baseTime+63, 0), + expectedResult: time.Unix(baseTime+63, 0), + description: "Persisted has second=58, current has " + + "second=3 (next minute). Current is later " + + "overall. Verifies .Unix() not .Second().", + }, + { + name: "higher second component but " + + "earlier time", + srcNodeLastUpdate: time.Unix(baseTime+63, 0), + nodeLastUpdate: time.Unix(baseTime+58, 0), + expectedResult: time.Unix(baseTime+64, 0), + description: "Persisted has second=3 (next minute), " + + "current has second=58. Persisted is later " + + "overall. Verifies .Unix() not .Second().", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + result := calculateNodeAnnouncementTimestamp( + tc.srcNodeLastUpdate, + tc.nodeLastUpdate, + ) + + // Verify we got the expected result. + require.Equal( + t, tc.expectedResult, result, + "Unexpected result: %s", tc.description, + ) + + // Verify result is strictly greater than persisted + // timestamp. This is an additional check to ensure + // the result is strictly greater than the persisted + // timestamp. + require.Greater( + t, result.Unix(), tc.srcNodeLastUpdate.Unix(), + "Result must be strictly greater than "+ + "persisted timestamp: %s", + tc.description, + ) + }) + } +} From 99b136e39e0b8ed73dc1ece2d2c6b4e7cca7a651 Mon Sep 17 00:00:00 2001 From: ziggie Date: Tue, 16 Dec 2025 18:18:22 +0100 Subject: [PATCH 043/134] docs: add release-notes for LND 20.1 (cherry picked from commit e5b0704d7384e284dea50e6f86121f06b0772443) --- docs/release-notes/release-notes-0.20.1.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/release-notes/release-notes-0.20.1.md b/docs/release-notes/release-notes-0.20.1.md index 2967d7df8..4c08c84c8 100644 --- a/docs/release-notes/release-notes-0.20.1.md +++ b/docs/release-notes/release-notes-0.20.1.md @@ -36,6 +36,12 @@ condition](https://github.com/lightningnetwork/lnd/pull/10371) which could prevent a node from starting up if two goroutines attempt to update the node's announcement at the same time. + +* [Fix timestamp comparison in source node + updates](https://github.com/lightningnetwork/lnd/pull/10449) that could still + cause "sql: no rows in result set" startup errors. The previous fix (#10371) + addressed concurrent updates with equal timestamps, but the seconds-only + comparison could still fail when restarting with different minute/hour values. * [Fix a startup issue in LND when encountering a deserialization issue](https://github.com/lightningnetwork/lnd/pull/10383) From 6c656a6af657e6ec7438d758fa0e81f24d269ad0 Mon Sep 17 00:00:00 2001 From: Abdullahi Yunus Date: Tue, 11 Nov 2025 12:51:49 +0100 Subject: [PATCH 044/134] graphdb: add benchmark for isPublicNode query In this commit we add a benchmark to test the performance of IsPublicNode query. (cherry picked from commit 86cde4b93f9fc5b01fced53489aa3224e9b9b7ea) --- graph/db/graph_test.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/graph/db/graph_test.go b/graph/db/graph_test.go index 41954bc2d..b5e7a99eb 100644 --- a/graph/db/graph_test.go +++ b/graph/db/graph_test.go @@ -1650,6 +1650,8 @@ func TestGraphCacheTraversal(t *testing.T) { require.Equal(t, numChannels*2*(numNodes-1), numNodeChans) } +// fillTestGraph fills the graph with a given number of nodes and create a given +// number of channels between each node. func fillTestGraph(t testing.TB, graph *ChannelGraph, numNodes, numChannels int) (map[uint64]struct{}, []*models.Node) { @@ -4052,6 +4054,28 @@ func TestNodeIsPublic(t *testing.T) { ) } +// BenchmarkIsPublicNode measures the performance of IsPublicNode when checking +// a large number of nodes. +func BenchmarkIsPublicNode(b *testing.B) { + graph := MakeTestGraph(b) + + // Create a graph with a reasonable number of nodes and channels. + numNodes := 100 + numChans := 4 + _, nodes := fillTestGraph(b, graph, numNodes, numChans) + + // Use deterministic random number generator for reproducible results. + rng := prand.New(prand.NewSource(42)) + + for b.Loop() { + // Query random nodes to avoid query caching and better + // represent real-world query patterns. + nodePub := nodes[rng.Intn(len(nodes))].PubKeyBytes + _, err := graph.IsPublicNode(nodePub) + require.NoError(b, err) + } +} + // TestDisabledChannelIDs ensures that the disabled channels within the // disabledEdgePolicyBucket are managed properly and the list returned from // DisabledChannelIDs is correct. From 10aff6c580a7961a5ed12e6f03c0be69951e1ace Mon Sep 17 00:00:00 2001 From: Abdullahi Yunus Date: Mon, 10 Nov 2025 12:16:07 +0100 Subject: [PATCH 045/134] graph: use UNION for isPublicNode query In this commit we updated the IsPublicV1Node query to use UNION instead of OR, since sqlite struggles to efficiently use multiple indexes in a single query involving OR conditions across different columns. We use UNION ALL since the query doesn't care about duplicates. (cherry picked from commit ac2cec462c63e62a57977b18faae593c6a360718) --- sqldb/sqlc/graph.sql.go | 9 ++++++++- sqldb/sqlc/queries/graph.sql | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/sqldb/sqlc/graph.sql.go b/sqldb/sqlc/graph.sql.go index 8ed9333d7..0ce7780d5 100644 --- a/sqldb/sqlc/graph.sql.go +++ b/sqldb/sqlc/graph.sql.go @@ -2653,7 +2653,7 @@ const isPublicV1Node = `-- name: IsPublicV1Node :one SELECT EXISTS ( SELECT 1 FROM graph_channels c - JOIN graph_nodes n ON n.id = c.node_id_1 OR n.id = c.node_id_2 + JOIN graph_nodes n ON n.id = c.node_id_1 -- NOTE: we hard-code the version here since the clauses -- here that determine if a node is public is specific -- to the V1 gossip protocol. In V1, a node is public @@ -2665,6 +2665,13 @@ SELECT EXISTS ( WHERE c.version = 1 AND c.bitcoin_1_signature IS NOT NULL AND n.pub_key = $1 + UNION ALL + SELECT 1 + FROM graph_channels c + JOIN graph_nodes n ON n.id = c.node_id_2 + WHERE c.version = 1 + AND c.bitcoin_1_signature IS NOT NULL + AND n.pub_key = $1 ) ` diff --git a/sqldb/sqlc/queries/graph.sql b/sqldb/sqlc/queries/graph.sql index b9bee1822..a8ff040d9 100644 --- a/sqldb/sqlc/queries/graph.sql +++ b/sqldb/sqlc/queries/graph.sql @@ -77,7 +77,7 @@ LIMIT $3; SELECT EXISTS ( SELECT 1 FROM graph_channels c - JOIN graph_nodes n ON n.id = c.node_id_1 OR n.id = c.node_id_2 + JOIN graph_nodes n ON n.id = c.node_id_1 -- NOTE: we hard-code the version here since the clauses -- here that determine if a node is public is specific -- to the V1 gossip protocol. In V1, a node is public @@ -89,6 +89,13 @@ SELECT EXISTS ( WHERE c.version = 1 AND c.bitcoin_1_signature IS NOT NULL AND n.pub_key = $1 + UNION ALL + SELECT 1 + FROM graph_channels c + JOIN graph_nodes n ON n.id = c.node_id_2 + WHERE c.version = 1 + AND c.bitcoin_1_signature IS NOT NULL + AND n.pub_key = $1 ); -- name: DeleteUnconnectedNodes :many From f1332fe4a798b92b4947e3c01fb966b75aa71d31 Mon Sep 17 00:00:00 2001 From: ziggie Date: Mon, 5 Jan 2026 17:02:21 +0100 Subject: [PATCH 046/134] itest: fix endorsement itests Due to the signaling period expiring tests had to be adopted bc they were not taking the activation time period into account. (cherry picked from commit 5f30797738674f6b9fcdc25165cc831cbbb40d95) --- itest/lnd_experimental_endorsement.go | 27 +++++++++++++++++++-------- itest/lnd_forward_interceptor_test.go | 17 ++++++++++++++--- lntest/utils.go | 17 +++++++++++++++++ 3 files changed, 50 insertions(+), 11 deletions(-) diff --git a/itest/lnd_experimental_endorsement.go b/itest/lnd_experimental_endorsement.go index 7b0fc21a0..67f5e30c0 100644 --- a/itest/lnd_experimental_endorsement.go +++ b/itest/lnd_experimental_endorsement.go @@ -57,12 +57,18 @@ func testEndorsement(ht *lntest.HarnessTest, aliceEndorse bool) { FeeLimitMsat: math.MaxInt64, } - expectedValue := []byte{lnwire.ExperimentalUnendorsed} - if aliceEndorse { - expectedValue = []byte{lnwire.ExperimentalEndorsed} - t := uint64(lnwire.ExperimentalEndorsementType) - sendReq.FirstHopCustomRecords = map[uint64][]byte{ - t: expectedValue, + var expectedValue []byte + hasEndorsement := lntest.ExperimentalEndorsementActive() + + if hasEndorsement { + if aliceEndorse { + expectedValue = []byte{lnwire.ExperimentalEndorsed} + t := uint64(lnwire.ExperimentalEndorsementType) + sendReq.FirstHopCustomRecords = map[uint64][]byte{ + t: expectedValue, + } + } else { + expectedValue = []byte{lnwire.ExperimentalUnendorsed} } } @@ -70,8 +76,13 @@ func testEndorsement(ht *lntest.HarnessTest, aliceEndorse bool) { // Validate that our signal (positive or zero) propagates until carol // and then is dropped because she has disabled the feature. - validateEndorsedAndResume(ht, bobIntercept, true, expectedValue) - validateEndorsedAndResume(ht, carolIntercept, true, expectedValue) + // When the endorsement experiment is not active, no signal is sent. + validateEndorsedAndResume( + ht, bobIntercept, hasEndorsement, expectedValue, + ) + validateEndorsedAndResume( + ht, carolIntercept, hasEndorsement, expectedValue, + ) validateEndorsedAndResume(ht, daveIntercept, false, nil) var preimage lntypes.Preimage diff --git a/itest/lnd_forward_interceptor_test.go b/itest/lnd_forward_interceptor_test.go index e8c1f418a..fc9a3f904 100644 --- a/itest/lnd_forward_interceptor_test.go +++ b/itest/lnd_forward_interceptor_test.go @@ -434,14 +434,25 @@ func testForwardInterceptorRestart(ht *lntest.HarnessTest) { // We should get another notification about the held HTLC. packet = ht.ReceiveHtlcInterceptor(bobInterceptor) - require.Len(ht, packet.InWireCustomRecords, 2) + // Check the expected number of custom records based on whether the + // endorsement experiment is still active. + expectedLen := 1 + if lntest.ExperimentalEndorsementActive() { + expectedLen = 2 + } + require.Len(ht, packet.InWireCustomRecords, expectedLen) require.Equal(ht, lntest.CustomRecordsWithUnendorsed(customRecords), packet.InWireCustomRecords) // And now we forward the payment at Carol, expecting only an - // endorsement signal in our incoming custom records. + // endorsement signal in our incoming custom records (if the experiment + // is still active). packet = ht.ReceiveHtlcInterceptor(carolInterceptor) - require.Len(ht, packet.InWireCustomRecords, 1) + expectedCarolLen := 0 + if lntest.ExperimentalEndorsementActive() { + expectedCarolLen = 1 + } + require.Len(ht, packet.InWireCustomRecords, expectedCarolLen) err = carolInterceptor.Send(&routerrpc.ForwardHtlcInterceptResponse{ IncomingCircuitKey: packet.IncomingCircuitKey, Action: actionResume, diff --git a/lntest/utils.go b/lntest/utils.go index a07b06436..ab998ecf8 100644 --- a/lntest/utils.go +++ b/lntest/utils.go @@ -7,9 +7,11 @@ import ( "os" "strconv" "strings" + "time" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lntest/wait" @@ -288,6 +290,15 @@ func CalcStaticFeeBuffer(c lnrpc.CommitmentType, numHTLCs int) btcutil.Amount { func CustomRecordsWithUnendorsed( originalRecords lnwire.CustomRecords) map[uint64][]byte { + if !ExperimentalEndorsementActive() { + // Return nil if there are no records, to match wire encoding. + if len(originalRecords) == 0 { + return nil + } + + return originalRecords.Copy() + } + return originalRecords.MergedCopy(map[uint64][]byte{ uint64(lnwire.ExperimentalEndorsementType): { lnwire.ExperimentalUnendorsed, @@ -295,6 +306,12 @@ func CustomRecordsWithUnendorsed( ) } +// ExperimentalEndorsementActive returns true if the experimental endorsement +// window is still open. +func ExperimentalEndorsementActive() bool { + return time.Now().Before(lnd.EndorsementExperimentEnd) +} + // LnrpcOutpointToStr returns a string representation of an lnrpc.OutPoint. func LnrpcOutpointToStr(outpoint *lnrpc.OutPoint) string { return fmt.Sprintf("%s:%d", outpoint.TxidStr, outpoint.OutputIndex) From 580e820b6aaf7c2dccb37075a9734ff822d92043 Mon Sep 17 00:00:00 2001 From: Abdullahi Yunus Date: Mon, 5 Jan 2026 20:31:45 +0100 Subject: [PATCH 047/134] docs: add release note (cherry picked from commit 68f558c865de0297fd7de7caf1946f16282e4ddd) --- docs/release-notes/release-notes-0.20.1.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/release-notes/release-notes-0.20.1.md b/docs/release-notes/release-notes-0.20.1.md index 4c08c84c8..879ac9ec8 100644 --- a/docs/release-notes/release-notes-0.20.1.md +++ b/docs/release-notes/release-notes-0.20.1.md @@ -111,6 +111,10 @@ safe single-writer behavior until the wallet subsystem is fully concurrent-safe. +* [Modified the query for `IsPublicV1Node`](https://github.com/lightningnetwork/lnd/pull/10356) + to use `UNION ALL` instead of `OR` conditions in the `WHERE` clause, improving + performance when checking for public nodes especially in large graphs when using `SQL` backends. + ## Deprecations # Technical and Architectural Updates @@ -126,5 +130,6 @@ # Contributors (Alphabetical Order) +* Abdulkbk * bitromortac * Ziggie From 57069cf3a1165c3898342cf90ab910a725d93895 Mon Sep 17 00:00:00 2001 From: ziggie Date: Mon, 22 Dec 2025 09:53:51 +0100 Subject: [PATCH 048/134] channeldb: fix race condition in link node pruning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit fixes a critical race condition in MarkChanFullyClosed and pruneLinkNode where link nodes could be incorrectly deleted despite having pending or open channels. The race occurred because the check for open channels and the link node deletion happened in separate database transactions: Thread A: TX1 checks open channels → [] (empty) Thread A: TX1 commits Thread B: Opens new channel with same peer Thread A: TX2 deletes link node (using stale data) Result: Link node deleted despite pending channel existing This creates a TOCTOU (time-of-check to time-of-use) vulnerability where database state changes between reading the channel count and deleting the node. Fix for MarkChanFullyClosed: - Move link node deletion into the same transaction as the channel closing check, making the check-and-delete operation atomic Fix for pruneLinkNode: - Add double-check within the write transaction to verify no channels were opened since the caller's initial check - Maintains performance by keeping early return for common case - Prevents deletion if channels exist at delete time This ensures the invariant: "link node exists iff channels exist" is never violated, preventing database corruption and potential connection issues. (cherry picked from commit 51f3c6f5286f5c6bfbc906b1861c647e5fcba61f) --- channeldb/db.go | 94 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 61 insertions(+), 33 deletions(-) diff --git a/channeldb/db.go b/channeldb/db.go index 00b29f65f..6064975a3 100644 --- a/channeldb/db.go +++ b/channeldb/db.go @@ -1363,11 +1363,7 @@ func (c *ChannelStateDB) FetchClosedChannelForID(cid lnwire.ChannelID) ( // the pending funds in a channel that has been forcibly closed have been // swept. func (c *ChannelStateDB) MarkChanFullyClosed(chanPoint *wire.OutPoint) error { - var ( - openChannels []*OpenChannel - pruneLinkNode *btcec.PublicKey - ) - err := kvdb.Update(c.backend, func(tx kvdb.RwTx) error { + return kvdb.Update(c.backend, func(tx kvdb.RwTx) error { var b bytes.Buffer if err := graphdb.WriteOutpoint(&b, chanPoint); err != nil { return err @@ -1413,44 +1409,72 @@ func (c *ChannelStateDB) MarkChanFullyClosed(chanPoint *wire.OutPoint) error { // other open channels with this peer. If we don't we'll // garbage collect it to ensure we don't establish persistent // connections to peers without open channels. - pruneLinkNode = chanSummary.RemotePub - openChannels, err = c.fetchOpenChannels( - tx, pruneLinkNode, - ) + remotePub := chanSummary.RemotePub + openChannels, err := c.fetchOpenChannels(tx, remotePub) if err != nil { return fmt.Errorf("unable to fetch open channels for "+ "peer %x: %v", - pruneLinkNode.SerializeCompressed(), err) + remotePub.SerializeCompressed(), err) + } + + if len(openChannels) > 0 { + return nil + } + + // If there are no open channels with this peer, prune the + // link node. We do this within the same transaction to avoid + // a race condition where a new channel could be opened + // between this check and the deletion. + log.Infof("Pruning link node %x with zero open "+ + "channels from database", + remotePub.SerializeCompressed()) + + err = deleteLinkNode(tx, remotePub) + if err != nil { + return fmt.Errorf("unable to delete link "+ + "node: %w", err) } return nil - }, func() { - openChannels = nil - pruneLinkNode = nil - }) - if err != nil { - return err - } - - // Decide whether we want to remove the link node, based upon the number - // of still open channels. - return c.pruneLinkNode(openChannels, pruneLinkNode) + }, func() {}) } // pruneLinkNode determines whether we should garbage collect a link node from -// the database due to no longer having any open channels with it. If there are -// any left, then this acts as a no-op. -func (c *ChannelStateDB) pruneLinkNode(openChannels []*OpenChannel, - remotePub *btcec.PublicKey) error { +// the database due to no longer having any open channels with it. +// +// NOTE: This function should be called after an initial check shows no open +// channels exist. It will double-check within a write transaction to avoid a +// race condition where a channel could be opened between the initial check +// and the deletion. +func (c *ChannelStateDB) pruneLinkNode(remotePub *btcec.PublicKey) error { + return kvdb.Update(c.backend, func(tx kvdb.RwTx) error { + // Double-check for open channels to avoid deleting a link node + // if a channel was opened since the caller's initial check. + // + // NOTE: This avoids a race condition where a channel could be + // opened between the initial check and the deletion. + openChannels, err := c.fetchOpenChannels(tx, remotePub) + if err != nil { + return err + } + + // If channels exist now, don't prune. + if len(openChannels) > 0 { + return nil + } + + // No open channels, safe to prune the link node. + log.Infof("Pruning link node %x with zero open channels "+ + "from database", + remotePub.SerializeCompressed()) + + err = deleteLinkNode(tx, remotePub) + if err != nil { + return fmt.Errorf("unable to prune link node: %w", err) + } - if len(openChannels) > 0 { return nil - } - - log.Infof("Pruning link node %x with zero open channels from database", - remotePub.SerializeCompressed()) - - return c.linkNodeDB.DeleteLinkNode(remotePub) + }, func() {}) } // PruneLinkNodes attempts to prune all link nodes found within the database @@ -1479,7 +1503,11 @@ func (c *ChannelStateDB) PruneLinkNodes() error { return err } - err = c.pruneLinkNode(openChannels, linkNode.IdentityPub) + if len(openChannels) > 0 { + continue + } + + err = c.pruneLinkNode(linkNode.IdentityPub) if err != nil { return err } From f5527e1e60bdbbd11d1ae87aca637b4db0b6e9e3 Mon Sep 17 00:00:00 2001 From: ziggie Date: Wed, 7 Jan 2026 09:21:24 +0100 Subject: [PATCH 049/134] multi: make sure previous inconsitent states are fixed We make sure that nodes previously suffering from this error will have a consitent db view when restarting their node. (cherry picked from commit d9fb9092b656c7d9fbdeef163dd7270ad9e8c259) --- channeldb/db.go | 87 ++++++++++++++ channeldb/nodes.go | 91 +++++++++++++++ channeldb/nodes_test.go | 243 ++++++++++++++++++++++++++++++++++++++++ server.go | 20 +++- 4 files changed, 438 insertions(+), 3 deletions(-) diff --git a/channeldb/db.go b/channeldb/db.go index 6064975a3..91f188628 100644 --- a/channeldb/db.go +++ b/channeldb/db.go @@ -1516,6 +1516,93 @@ func (c *ChannelStateDB) PruneLinkNodes() error { return nil } +// RepairLinkNodes scans all channels in the database and ensures that a +// link node exists for each remote peer. This should be called on startup to +// ensure that our database is consistent. +// +// NOTE: This function is designed to repair database inconsistencies that may +// have occurred due to the race condition in link node pruning (where link +// nodes could be incorrectly deleted while channels still existed). This can +// be removed once we move to native sql. +func (c *ChannelStateDB) RepairLinkNodes(network wire.BitcoinNet) error { + // In a single read transaction, build a list of all peers with open + // channels and check which ones are missing link nodes. + var missingPeers []*btcec.PublicKey + + err := kvdb.View(c.backend, func(tx kvdb.RTx) error { + openChanBucket := tx.ReadBucket(openChannelBucket) + if openChanBucket == nil { + return ErrNoActiveChannels + } + + var peersWithChannels []*btcec.PublicKey + + err := openChanBucket.ForEach(func(nodePubBytes, + _ []byte) error { + + nodePub, err := btcec.ParsePubKey(nodePubBytes) + if err != nil { + return err + } + + channels, err := c.fetchOpenChannels(tx, nodePub) + if err != nil { + return err + } + + if len(channels) > 0 { + peersWithChannels = append( + peersWithChannels, nodePub, + ) + } + + return nil + }) + if err != nil { + return err + } + + // Now check which peers are missing link nodes within the + // same transaction. + missingPeers, err = c.linkNodeDB.FindMissingLinkNodes( + tx, peersWithChannels, + ) + + return err + }, func() { + missingPeers = nil + }) + if err != nil && !errors.Is(err, ErrNoActiveChannels) { + return fmt.Errorf("unable to fetch channels: %w", err) + } + + // Early exit if no repairs needed. + if len(missingPeers) == 0 { + return nil + } + + // Create all missing link nodes in a single write transaction + // using the LinkNodeDB abstraction. + linkNodesToCreate := make([]*LinkNode, 0, len(missingPeers)) + for _, remotePub := range missingPeers { + linkNode := NewLinkNode(c.linkNodeDB, network, remotePub) + linkNodesToCreate = append(linkNodesToCreate, linkNode) + + log.Infof("Repairing missing link node for peer %x", + remotePub.SerializeCompressed()) + } + + err = c.linkNodeDB.CreateLinkNodes(nil, linkNodesToCreate) + if err != nil { + return err + } + + log.Infof("Repaired %d missing link nodes on startup", + len(missingPeers)) + + return nil +} + // ChannelShell is a shell of a channel that is meant to be used for channel // recovery purposes. It contains a minimal OpenChannel instance along with // addresses for that target node. diff --git a/channeldb/nodes.go b/channeldb/nodes.go index b17d5c360..70f6fad8b 100644 --- a/channeldb/nodes.go +++ b/channeldb/nodes.go @@ -2,6 +2,8 @@ package channeldb import ( "bytes" + "errors" + "fmt" "io" "net" "time" @@ -134,6 +136,95 @@ type LinkNodeDB struct { backend kvdb.Backend } +// FindMissingLinkNodes checks which of the provided public keys do not have +// corresponding link nodes in the database. If tx is nil, a new read +// transaction will be created. Otherwise, the provided transaction is used, +// allowing this to be part of a larger batch operation. +func (l *LinkNodeDB) FindMissingLinkNodes(tx kvdb.RTx, + pubKeys []*btcec.PublicKey) ([]*btcec.PublicKey, error) { + + var missing []*btcec.PublicKey + + findMissing := func(readTx kvdb.RTx) error { + nodeMetaBucket := readTx.ReadBucket(nodeInfoBucket) + if nodeMetaBucket == nil { + // If the bucket doesn't exist, all peers are missing. + missing = pubKeys + return nil + } + + for _, pubKey := range pubKeys { + _, err := fetchLinkNode(readTx, pubKey) + if err == nil { + // Link node exists. + continue + } + + if !errors.Is(err, ErrNodeNotFound) { + return fmt.Errorf("unable to check link node "+ + "for peer %x: %w", + pubKey.SerializeCompressed(), err) + } + + // Link node doesn't exist. + missing = append(missing, pubKey) + } + + return nil + } + + // If no transaction provided, create our own. + if tx == nil { + err := kvdb.View(l.backend, findMissing, func() { + missing = nil + }) + + return missing, err + } + + // Use the provided transaction. + err := findMissing(tx) + + return missing, err +} + +// CreateLinkNodes creates multiple link nodes. If tx is nil, a new write +// transaction will be created. Otherwise, the provided transaction is used, +// allowing this to be part of a larger batch operation. +func (l *LinkNodeDB) CreateLinkNodes(tx kvdb.RwTx, + linkNodes []*LinkNode) error { + + createNodes := func(writeTx kvdb.RwTx) error { + nodeMetaBucket, err := writeTx.CreateTopLevelBucket( + nodeInfoBucket, + ) + if err != nil { + return err + } + + for _, linkNode := range linkNodes { + err := putLinkNode(nodeMetaBucket, linkNode) + if err != nil { + pubKey := linkNode.IdentityPub. + SerializeCompressed() + + return fmt.Errorf("unable to create link "+ + "node for peer %x: %w", pubKey, err) + } + } + + return nil + } + + // If no transaction provided, create our own. + if tx == nil { + return kvdb.Update(l.backend, createNodes, func() {}) + } + + // Use the provided transaction. + return createNodes(tx) +} + // DeleteLinkNode removes the link node with the given identity from the // database. func (l *LinkNodeDB) DeleteLinkNode(identity *btcec.PublicKey) error { diff --git a/channeldb/nodes_test.go b/channeldb/nodes_test.go index b54cf0045..a88e45228 100644 --- a/channeldb/nodes_test.go +++ b/channeldb/nodes_test.go @@ -8,6 +8,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/kvdb" "github.com/stretchr/testify/require" ) @@ -129,3 +130,245 @@ func TestDeleteLinkNode(t *testing.T) { t.Fatal("should not have found link node in db, but did") } } + +// TestRepairLinkNodes tests that the RepairLinkNodes function correctly +// identifies and repairs missing link nodes for channels that exist in the +// database. +func TestRepairLinkNodes(t *testing.T) { + t.Parallel() + + fullDB, err := MakeTestDB(t) + require.NoError(t, err, "unable to make test database") + + cdb := fullDB.ChannelStateDB() + + // Create a test channel and save it to the database. + channel1 := createTestChannel(t, cdb) + + // Manually create a link node for the channel. + linkNode1 := NewLinkNode( + cdb.linkNodeDB, wire.MainNet, channel1.IdentityPub, + ) + err = linkNode1.Sync() + require.NoError(t, err, "unable to sync link node") + + // Verify that link node was created. + fetchedLinkNode, err := cdb.linkNodeDB.FetchLinkNode( + channel1.IdentityPub, + ) + require.NoError(t, err, "link node should exist") + require.NotNil(t, fetchedLinkNode, "link node should not be nil") + + // Now, manually delete one of the link nodes to simulate the race + // condition scenario where a link node was incorrectly pruned. + err = cdb.linkNodeDB.DeleteLinkNode(channel1.IdentityPub) + require.NoError(t, err, "unable to delete link node") + + // Verify the link node is gone. + _, err = cdb.linkNodeDB.FetchLinkNode(channel1.IdentityPub) + require.ErrorIs( + t, err, ErrNodeNotFound, + "link node should be deleted", + ) + + // Now run the repair function with the correct network. + err = cdb.RepairLinkNodes(wire.MainNet) + require.NoError(t, err, "repair should succeed") + + // Verify that the link node has been restored. + repairedLinkNode, err := cdb.linkNodeDB.FetchLinkNode( + channel1.IdentityPub, + ) + require.NoError(t, err, "repaired link node should exist") + require.NotNil( + t, repairedLinkNode, "repaired link node should not be nil", + ) + require.Equal( + t, wire.MainNet, repairedLinkNode.Network, + "repaired link node should have correct network", + ) + + // Run repair again - it should be idempotent and not fail. + err = cdb.RepairLinkNodes(wire.MainNet) + require.NoError(t, err, "second repair should succeed") + + // Test with different network to ensure network parameter is used. + err = cdb.linkNodeDB.DeleteLinkNode(channel1.IdentityPub) + require.NoError(t, err, "unable to delete link node") + + err = cdb.RepairLinkNodes(wire.TestNet3) + require.NoError(t, err, "repair with testnet should succeed") + + repairedLinkNode, err = cdb.linkNodeDB.FetchLinkNode( + channel1.IdentityPub, + ) + require.NoError(t, err, "repaired link node should exist") + require.Equal( + t, wire.TestNet3, repairedLinkNode.Network, + "repaired link node should use provided network", + ) +} + +// TestFindMissingLinkNodes tests the FindMissingLinkNodes method with various +// scenarios. +func TestFindMissingLinkNodes(t *testing.T) { + t.Parallel() + + fullDB, err := MakeTestDB(t) + require.NoError(t, err, "unable to make test database") + + cdb := fullDB.ChannelStateDB() + + // Create three test public keys. + _, pub1 := btcec.PrivKeyFromBytes(key[:]) + _, pub2 := btcec.PrivKeyFromBytes(rev[:]) + testKey := [32]byte{0x03} + _, pub3 := btcec.PrivKeyFromBytes(testKey[:]) + + // Test 1: All nodes missing (empty database). + allPubs := []*btcec.PublicKey{pub1, pub2, pub3} + missing, err := cdb.linkNodeDB.FindMissingLinkNodes(nil, allPubs) + require.NoError(t, err, "FindMissingLinkNodes should succeed") + require.Len(t, missing, 3, "all nodes should be missing") + + // Test 2: Create one link node, verify only 2 are missing. + node1 := NewLinkNode(cdb.linkNodeDB, wire.MainNet, pub1) + err = node1.Sync() + require.NoError(t, err, "unable to sync link node") + + missing, err = cdb.linkNodeDB.FindMissingLinkNodes(nil, allPubs) + require.NoError(t, err, "FindMissingLinkNodes should succeed") + require.Len(t, missing, 2, "two nodes should be missing") + require.Contains(t, missing, pub2, "pub2 should be missing") + require.Contains(t, missing, pub3, "pub3 should be missing") + require.NotContains(t, missing, pub1, "pub1 should exist") + + // Test 3: Create remaining nodes, verify none are missing. + node2 := NewLinkNode(cdb.linkNodeDB, wire.MainNet, pub2) + err = node2.Sync() + require.NoError(t, err, "unable to sync link node") + + node3 := NewLinkNode(cdb.linkNodeDB, wire.MainNet, pub3) + err = node3.Sync() + require.NoError(t, err, "unable to sync link node") + + missing, err = cdb.linkNodeDB.FindMissingLinkNodes(nil, allPubs) + require.NoError(t, err, "FindMissingLinkNodes should succeed") + require.Len(t, missing, 0, "no nodes should be missing") + + // Test 4: Use with a provided transaction. + err = cdb.linkNodeDB.DeleteLinkNode(pub2) + require.NoError(t, err, "unable to delete link node") + + backend := fullDB.ChannelStateDB().backend + err = kvdb.View(backend, func(tx kvdb.RTx) error { + missing, err := cdb.linkNodeDB.FindMissingLinkNodes( + tx, allPubs, + ) + require.NoError(t, err, "FindMissingLinkNodes should succeed") + require.Len(t, missing, 1, "one node should be missing") + require.Contains(t, missing, pub2, "pub2 should be missing") + + return nil + }, func() {}) + require.NoError(t, err, "transaction should succeed") + + // Test 5: Empty input list. + missing, err = cdb.linkNodeDB.FindMissingLinkNodes(nil, nil) + require.NoError(t, err, "FindMissingLinkNodes should succeed") + require.Len(t, missing, 0, "no nodes should be missing for empty input") +} + +// TestCreateLinkNodes tests the CreateLinkNodes method with various scenarios. +func TestCreateLinkNodes(t *testing.T) { + t.Parallel() + + fullDB, err := MakeTestDB(t) + require.NoError(t, err, "unable to make test database") + + cdb := fullDB.ChannelStateDB() + + // Create three test public keys and link nodes. + _, pub1 := btcec.PrivKeyFromBytes(key[:]) + _, pub2 := btcec.PrivKeyFromBytes(rev[:]) + testKey := [32]byte{0x03} + _, pub3 := btcec.PrivKeyFromBytes(testKey[:]) + + node1 := NewLinkNode(cdb.linkNodeDB, wire.MainNet, pub1) + node2 := NewLinkNode(cdb.linkNodeDB, wire.TestNet3, pub2) + node3 := NewLinkNode(cdb.linkNodeDB, wire.SimNet, pub3) + + // Test 1: Create multiple link nodes at once with nil transaction. + nodesToCreate := []*LinkNode{node1, node2, node3} + err = cdb.linkNodeDB.CreateLinkNodes(nil, nodesToCreate) + require.NoError(t, err, "CreateLinkNodes should succeed") + + // Verify all nodes were created correctly. + fetchedNode1, err := cdb.linkNodeDB.FetchLinkNode(pub1) + require.NoError(t, err, "node1 should exist") + require.Equal(t, wire.MainNet, fetchedNode1.Network, + "node1 should have correct network") + + fetchedNode2, err := cdb.linkNodeDB.FetchLinkNode(pub2) + require.NoError(t, err, "node2 should exist") + require.Equal(t, wire.TestNet3, fetchedNode2.Network, + "node2 should have correct network") + + fetchedNode3, err := cdb.linkNodeDB.FetchLinkNode(pub3) + require.NoError(t, err, "node3 should exist") + require.Equal(t, wire.SimNet, fetchedNode3.Network, + "node3 should have correct network") + + // Test 2: Create nodes within a provided transaction. + err = cdb.linkNodeDB.DeleteLinkNode(pub2) + require.NoError(t, err, "unable to delete link node") + + // Verify node2 is deleted. + _, err = cdb.linkNodeDB.FetchLinkNode(pub2) + require.ErrorIs(t, err, ErrNodeNotFound, "node2 should be deleted") + + // Recreate node2 using a provided transaction. + backend := fullDB.ChannelStateDB().backend + err = kvdb.Update(backend, func(tx kvdb.RwTx) error { + return cdb.linkNodeDB.CreateLinkNodes(tx, []*LinkNode{node2}) + }, func() {}) + require.NoError(t, err, "transaction should succeed") + + // Verify node2 was recreated. + fetchedNode2, err = cdb.linkNodeDB.FetchLinkNode(pub2) + require.NoError(t, err, "node2 should exist after recreation") + require.Equal(t, wire.TestNet3, fetchedNode2.Network, + "node2 should have correct network") + + // Test 3: Creating nodes that already exist should succeed + // (idempotent behavior). + err = cdb.linkNodeDB.CreateLinkNodes(nil, nodesToCreate) + require.NoError(t, err, "recreating existing nodes should succeed") + + // Verify nodes still exist with correct data. + fetchedNode1, err = cdb.linkNodeDB.FetchLinkNode(pub1) + require.NoError(t, err, "node1 should still exist") + require.Equal(t, wire.MainNet, fetchedNode1.Network, + "node1 should still have correct network") + + // Test 4: Empty input list. + err = cdb.linkNodeDB.CreateLinkNodes(nil, nil) + require.NoError( + t, err, "CreateLinkNodes with empty list should succeed", + ) + + // Test 5: Create single node. + testKey4 := [32]byte{0x04} + _, pub4 := btcec.PrivKeyFromBytes(testKey4[:]) + node4 := NewLinkNode(cdb.linkNodeDB, wire.MainNet, pub4) + + err = cdb.linkNodeDB.CreateLinkNodes(nil, []*LinkNode{node4}) + require.NoError( + t, err, "CreateLinkNodes with single node should succeed", + ) + + fetchedNode4, err := cdb.linkNodeDB.FetchLinkNode(pub4) + require.NoError(t, err, "node4 should exist") + require.Equal(t, wire.MainNet, fetchedNode4.Network, + "node4 should have correct network") +} diff --git a/server.go b/server.go index 4c141a321..3f38e8855 100644 --- a/server.go +++ b/server.go @@ -2128,6 +2128,21 @@ func (s *server) Start(ctx context.Context) error { cleanup := cleaner{} s.start.Do(func() { + // Before starting any subsystems, repair any link nodes that + // may have been incorrectly pruned due to the race condition + // that was fixed in the link node pruning logic. This must + // happen before the chain arbitrator and other subsystems load + // channels, to ensure the invariant "link node exists iff + // channels exist" is maintained. + err := s.chanStateDB.RepairLinkNodes(s.cfg.ActiveNetParams.Net) + if err != nil { + srvrLog.Errorf("Failed to repair link nodes: %v", err) + + startErr = err + + return + } + cleanup = cleanup.add(s.customMessageServer.Stop) if err := s.customMessageServer.Start(); err != nil { startErr = err @@ -2451,9 +2466,8 @@ func (s *server) Start(ctx context.Context) error { // With all the relevant sub-systems started, we'll now attempt // to establish persistent connections to our direct channel // collaborators within the network. Before doing so however, - // we'll prune our set of link nodes found within the database - // to ensure we don't reconnect to any nodes we no longer have - // open channels with. + // we'll prune our set of link nodes to ensure we don't + // reconnect to any nodes we no longer have open channels with. if err := s.chanStateDB.PruneLinkNodes(); err != nil { srvrLog.Errorf("Failed to prune link nodes: %v", err) From 8b471eadeb3dbe9a6e93cf6b43be49b8740bb07c Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Mon, 12 Jan 2026 14:05:21 +0100 Subject: [PATCH 050/134] discovery: enforce non-zero timestamp in gossip messages In this commit, we add validation for channel updates and node announcements to ensure that we reject gossip messages with zero timestamps at the discovery layer. From BOLT 7: "MUST set timestamp to greater than 0, AND to greater than any previously-sent channel_update for this short_channel_id." This validation is performed in the gossip handlers (handleNodeAnnouncement and handleChanUpdate) rather than at the wire protocol level. This approach ensures we can still decode messages from disk or embedded in onion errors while rejecting invalid gossip from peers. Remote peers sending zero-timestamp gossip will have their ban score incremented. (cherry picked from commit cad1b957bfa2bc2c524d4c930470ce2b55ae8a11) --- discovery/gossiper.go | 37 +++++++++++ discovery/gossiper_test.go | 72 ++++++++++++++++++++++ docs/release-notes/release-notes-0.20.1.md | 6 ++ 3 files changed, 115 insertions(+) diff --git a/discovery/gossiper.go b/discovery/gossiper.go index 50dd3a57a..ee0a0d79e 100644 --- a/discovery/gossiper.go +++ b/discovery/gossiper.go @@ -2488,6 +2488,22 @@ func (d *AuthenticatedGossiper) handleNodeAnnouncement(ctx context.Context, "node=%x, source=%x", nMsg.peer, timestamp, nodeAnn.NodeID, nMsg.source.SerializeCompressed()) + // Although not explicitly required by BOLT 7 for node announcements + // (unlike channel updates), we still enforce non-zero timestamps as a + // sanity check. A timestamp of zero is likely indicative of a bug or + // uninitialized message. + if nodeAnn.Timestamp == 0 { + err := fmt.Errorf("rejecting node announcement with zero "+ + "timestamp for node %x", nodeAnn.NodeID) + + log.Warnf("Rejecting node announcement from peer=%v: %v", + nMsg.peer, err) + + nMsg.err <- err + + return nil, false + } + // We'll quickly ask the router if it already has a newer update for // this node so we can skip validating signatures if not required. if d.cfg.Graph.IsStaleNode(ctx, nodeAnn.NodeID, timestamp) { @@ -3033,6 +3049,27 @@ func (d *AuthenticatedGossiper) handleChanUpdate(ctx context.Context, // quickly reject it. timestamp := time.Unix(int64(upd.Timestamp), 0) + // Per BOLT 7, the timestamp MUST be greater than 0. + if upd.Timestamp == 0 { + err := fmt.Errorf("rejecting channel update with zero "+ + "timestamp for short_chan_id(%v)", shortChanID) + + // Only increase ban score for remote peers. + if nMsg.isRemote { + log.Warnf("Increasing ban score for peer=%v: %v", + nMsg.peer, err) + + dcErr := d.handleBadPeer(nMsg.peer) + if dcErr != nil { + err = dcErr + } + } + + nMsg.err <- err + + return nil, false + } + // Fetch the SCID we should be using to lock the channelMtx and make // graph queries with. graphScid, err := d.cfg.FindBaseByAlias(upd.ShortChannelID) diff --git a/discovery/gossiper_test.go b/discovery/gossiper_test.go index 521e08963..18ef6a67a 100644 --- a/discovery/gossiper_test.go +++ b/discovery/gossiper_test.go @@ -2930,6 +2930,78 @@ func TestExtraDataNodeAnnouncementValidation(t *testing.T) { require.NoError(t, err, "unable to process announcement") } +// TestZeroTimestampNodeAnnouncementRejection tests that a NodeAnnouncement with +// a zero timestamp is rejected per BOLT 7. +func TestZeroTimestampNodeAnnouncementRejection(t *testing.T) { + t.Parallel() + ctx := t.Context() + + tCtx, err := createTestCtx(t, 0, false) + require.NoError(t, err, "can't create context") + + remotePeer := &mockPeer{ + remoteKeyPriv1.PubKey(), nil, nil, atomic.Bool{}, + } + + // Create a node announcement with a zero timestamp. + nodeAnn, err := createNodeAnnouncement(remoteKeyPriv1, 0) + require.NoError(t, err, "can't create node announcement") + + // Processing the announcement should fail with a zero timestamp error. + select { + case err = <-tCtx.gossiper.ProcessRemoteAnnouncement( + ctx, nodeAnn, remotePeer, + ): + case <-time.After(2 * time.Second): + t.Fatal("did not process remote announcement") + } + require.Error(t, err) + require.Contains(t, err.Error(), "zero timestamp") +} + +// TestZeroTimestampChannelUpdateRejection tests that a ChannelUpdate with a +// zero timestamp is rejected per BOLT 7. +func TestZeroTimestampChannelUpdateRejection(t *testing.T) { + t.Parallel() + ctx := t.Context() + + tCtx, err := createTestCtx(t, 0, false) + require.NoError(t, err, "can't create context") + + remotePeer := &mockPeer{ + remoteKeyPriv1.PubKey(), nil, nil, atomic.Bool{}, + } + + // First, we need to process a channel announcement so that the channel + // update has a valid channel to refer to. + chanAnn, err := tCtx.createRemoteChannelAnnouncement(0) + require.NoError(t, err, "unable to create chan ann") + + select { + case err = <-tCtx.gossiper.ProcessRemoteAnnouncement( + ctx, chanAnn, remotePeer, + ): + case <-time.After(2 * time.Second): + t.Fatal("did not process remote announcement") + } + require.NoError(t, err, "unable to process chan ann") + + // Now create a channel update with a zero timestamp. + chanUpdAnn, err := createUpdateAnnouncement(0, 0, remoteKeyPriv1, 0) + require.NoError(t, err, "unable to create chan update") + + // Processing the update should fail with a zero timestamp error. + select { + case err = <-tCtx.gossiper.ProcessRemoteAnnouncement( + ctx, chanUpdAnn, remotePeer, + ): + case <-time.After(2 * time.Second): + t.Fatal("did not process remote announcement") + } + require.Error(t, err) + require.Contains(t, err.Error(), "zero timestamp") +} + // assertBroadcast checks that num messages are being broadcasted from the // gossiper. The broadcasted messages are returned. func assertBroadcast(t *testing.T, ctx *testCtx, num int) []lnwire.Message { diff --git a/docs/release-notes/release-notes-0.20.1.md b/docs/release-notes/release-notes-0.20.1.md index 879ac9ec8..e726406a1 100644 --- a/docs/release-notes/release-notes-0.20.1.md +++ b/docs/release-notes/release-notes-0.20.1.md @@ -120,6 +120,12 @@ # Technical and Architectural Updates ## BOLT Spec Updates +* [Enforce non-zero timestamps](https://github.com/lightningnetwork/lnd/pull/10469) + for `channel_update` (as required by BOLT 7) and `node_announcement` messages. + Gossip messages with zero timestamps are now rejected. For `channel_update` + messages, remote peers sending such invalid messages will have their ban score + incremented. + ## Testing ## Database From b7b73b02e1a4ca85749c8078f978d41c59898f47 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Mon, 29 Dec 2025 14:43:36 -0800 Subject: [PATCH 051/134] discovery: add panic recovery for gossip message processing In this commit, we add a centralized panic recovery mechanism for gossip goroutines. This increases the robustness of message processing in the gossiper, as now we are able to keep on trucking in the face of logic errors that may lead to panics. We ensure that any deps are freed and we log the panic trace to help catch bugs in the future. (cherry picked from commit caf4850f7465706806aa573122886060a12f3f0c) --- discovery/gossiper.go | 81 +++++++++- discovery/gossiper_test.go | 309 +++++++++++++++++++++++++++++++++++++ 2 files changed, 389 insertions(+), 1 deletion(-) diff --git a/discovery/gossiper.go b/discovery/gossiper.go index ee0a0d79e..4c5044e29 100644 --- a/discovery/gossiper.go +++ b/discovery/gossiper.go @@ -5,6 +5,8 @@ import ( "context" "errors" "fmt" + "log/slog" + "runtime/debug" "strings" "sync" "sync/atomic" @@ -1610,7 +1612,7 @@ func (d *AuthenticatedGossiper) handleNetworkMessages(ctx context.Context, nMsg *networkMsg, deDuped *deDupedAnnouncements, jobID JobID) { defer d.wg.Done() - defer d.vb.CompleteJob() + defer d.finalizeGossipProcessing(ctx, "processing", nMsg, &jobID) // We should only broadcast this message forward if it originated from // us or it wasn't received as part of our initial historical sync. @@ -1666,6 +1668,83 @@ func (d *AuthenticatedGossiper) handleNetworkMessages(ctx context.Context, } } +// finalizeGossipProcessing handles cleanup for gossip message processing, +// including job completion and panic recovery. It guards gossip goroutines +// against panics to keep the daemon alive. On panic, it logs the error, +// signals dependents, and reports back to the caller if possible. +// +// NOTE: This function MUST be called via defer to recover from panics. +func (d *AuthenticatedGossiper) finalizeGossipProcessing(logCtx context.Context, + ctxStr string, nMsg *networkMsg, jobID *JobID) { + + // Always complete the job when provided, regardless of panic state. + // This ensures job slots are returned even if callers forget or + // misordering occurs. + if jobID != nil { + d.vb.CompleteJob() + } + + r := recover() + if r == nil { + return + } + + msgType := "unknown" + if nMsg != nil && nMsg.msg != nil { + msgType = nMsg.msg.MsgType().String() + } + + var peerPub string + if nMsg != nil && nMsg.peer != nil { + peerPub = route.NewVertex(nMsg.peer.IdentityKey()).String() + } else { + peerPub = "unknown" + } + + log.ErrorS(logCtx, "Panic during gossip message processing", + fmt.Errorf("%v", r), + slog.String("context", ctxStr), + slog.String("msg_type", msgType), + slog.String("peer", peerPub), + ) + // Truncate the stack trace to avoid filling up disk space if an + // attacker repeatedly triggers panics. + const maxStackSize = 8192 + stack := debug.Stack() + if len(stack) > maxStackSize { + stack = stack[:maxStackSize] + } + log.DebugS(logCtx, "Panic stack trace", + slog.String("stack", string(stack)), + ) + + // Signal any dependents waiting on this message so they don't block + // forever. + if nMsg != nil && nMsg.msg != nil && jobID != nil { + if err := d.vb.SignalDependents( + nMsg.msg, *jobID, + ); err != nil { + log.ErrorS(logCtx, "SignalDependents after panic failed", + err, + slog.String("msg_type", nMsg.msg.MsgType().String()), + ) + } + } + + // Send an error back to the caller if possible. + if nMsg != nil && nMsg.err != nil { + select { + case nMsg.err <- fmt.Errorf("panic while %s gossip "+ + "message %s: %v", ctxStr, msgType, r): + default: + log.WarnS(logCtx, "Unable to send panic error, "+ + "error channel blocked", nil, + slog.String("msg_type", msgType), + ) + } + } +} + // TODO(roasbeef): d/c peers that send updates not on our chain // InitSyncState is called by outside sub-systems when a connection is diff --git a/discovery/gossiper_test.go b/discovery/gossiper_test.go index 18ef6a67a..5738d6a0f 100644 --- a/discovery/gossiper_test.go +++ b/discovery/gossiper_test.go @@ -4926,3 +4926,312 @@ func assertChanChainRejection(t *testing.T, ctx *testCtx, require.NoError(t, err) require.True(t, isZombie, "edge should be marked as zombie") } + +// TestRecoverGossipPanic tests that the finalizeGossipProcessing function +// correctly handles panics in gossip goroutines by recovering, logging, and +// sending errors back to callers. +func TestRecoverGossipPanic(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + setupMsg func() (*networkMsg, chan error) + checkError bool + }{ + { + name: "panic with full message context", + setupMsg: func() (*networkMsg, chan error) { + errChan := make(chan error, 1) + return &networkMsg{ + msg: &lnwire.ChannelUpdate1{ + Timestamp: testTimestamp, + }, + peer: &mockPeer{ + remoteKeyPub1, nil, nil, + atomic.Bool{}, + }, + err: errChan, + }, errChan + }, + checkError: true, + }, + { + name: "panic with nil message", + setupMsg: func() (*networkMsg, chan error) { + errChan := make(chan error, 1) + return &networkMsg{ + msg: nil, + peer: nil, + err: errChan, + }, errChan + }, + checkError: true, + }, + { + name: "panic with nil error channel", + setupMsg: func() (*networkMsg, chan error) { + return &networkMsg{ + msg: &lnwire.ChannelUpdate1{ + Timestamp: testTimestamp, + }, + peer: nil, + err: nil, + }, nil + }, + checkError: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx, err := createTestCtx(t, proofMatureDelta, false) + require.NoError(t, err) + + nMsg, errChan := tc.setupMsg() + + // Initialize a proper job so CompleteJob has a slot + // to return. + var jobIDRef *JobID + if nMsg.msg != nil { + job, err := ctx.gossiper.vb.InitJobDependencies( + nMsg.msg, + ) + require.NoError(t, err) + jobIDRef = &job + } + + // Create a function that will panic and then recover. + panicked := make(chan struct{}) + go func() { + defer ctx.gossiper.finalizeGossipProcessing( + context.Background(), "testing", + nMsg, jobIDRef, + ) + defer close(panicked) + + panic("test panic") + }() + + // Wait for the goroutine to complete. + select { + case <-panicked: + case <-time.After(time.Second): + t.Fatal("timeout waiting for panic recovery") + } + + // If we expect an error to be sent back, verify it. + if tc.checkError { + require.NotNil(t, errChan, "test expects "+ + "error but errChan is nil") + } + if tc.checkError && errChan != nil { + select { + case err := <-errChan: + require.Error(t, err) + require.Contains( + t, err.Error(), "panic while", + ) + require.Contains( + t, err.Error(), "test panic", + ) + case <-time.After(time.Second): + t.Fatal("timeout waiting for error") + } + } + }) + } +} + +// TestRecoverGossipPanicBlockedErrorChannel verifies that the panic recovery +// does not hang when the error channel is unbuffered and not being read from. +// The recovery should use a non-blocking send with a default case. +func TestRecoverGossipPanicBlockedErrorChannel(t *testing.T) { + t.Parallel() + + ctx, err := createTestCtx(t, proofMatureDelta, false) + require.NoError(t, err) + + // Create an UNBUFFERED channel and don't read from it. + errChan := make(chan error) + + nMsg := &networkMsg{ + msg: &lnwire.ChannelUpdate1{Timestamp: testTimestamp}, + peer: &mockPeer{remoteKeyPub1, nil, nil, atomic.Bool{}}, + err: errChan, + } + + // Initialize a proper job so CompleteJob has a slot to return. + jobID, err := ctx.gossiper.vb.InitJobDependencies(nMsg.msg) + require.NoError(t, err) + + panicked := make(chan struct{}) + go func() { + defer ctx.gossiper.finalizeGossipProcessing( + context.Background(), "testing", nMsg, &jobID, + ) + defer close(panicked) + + panic("test panic") + }() + + // Should not hang - the default case should handle blocked channel. + select { + case <-panicked: + // Success - didn't hang. + case <-time.After(time.Second): + t.Fatal("panic recovery hung on blocked error channel") + } +} + +// TestRecoverGossipPanicSignalsDependents verifies that when a parent job +// panics during gossip processing, the panic recovery correctly signals +// dependent jobs via the validation barrier so they don't block forever. +func TestRecoverGossipPanicSignalsDependents(t *testing.T) { + t.Parallel() + + ctx, err := createTestCtx(t, proofMatureDelta, false) + require.NoError(t, err) + + // Create a channel announcement directly without mocks. We only need + // it to register with the validation barrier. + chanAnn := &lnwire.ChannelAnnouncement1{ + ShortChannelID: lnwire.NewShortChanIDFromInt(12345), + NodeID1: [33]byte{0x02}, + NodeID2: [33]byte{0x03}, + } + + // Register the channel announcement as a parent job. + parentJobID, err := ctx.gossiper.vb.InitJobDependencies(chanAnn) + require.NoError(t, err) + + // Create a channel update that depends on this channel announcement. + // Channel updates wait for their parent channel announcement. + chanUpdate := &lnwire.ChannelUpdate1{ + ShortChannelID: chanAnn.ShortChannelID, + Timestamp: testTimestamp, + } + + // Register the channel update as a child job. + childJobID, err := ctx.gossiper.vb.InitJobDependencies(chanUpdate) + require.NoError(t, err) + + // Start a goroutine that waits for the parent job to complete. + childDone := make(chan error, 1) + go func() { + err := ctx.gossiper.vb.WaitForParents(childJobID, chanUpdate) + childDone <- err + }() + + // Give the child goroutine time to start waiting. + time.Sleep(50 * time.Millisecond) + + // Now simulate the parent job panicking and recovering. + // The recovery should call SignalDependents. + errChan := make(chan error, 1) + nMsg := &networkMsg{ + msg: chanAnn, + peer: &mockPeer{ + remoteKeyPub1, nil, nil, atomic.Bool{}, + }, + err: errChan, + } + + panicked := make(chan struct{}) + go func() { + defer ctx.gossiper.finalizeGossipProcessing( + context.Background(), "testing", nMsg, &parentJobID, + ) + defer close(panicked) + + panic("parent job panic") + }() + + // Wait for the panic to be recovered. + select { + case <-panicked: + case <-time.After(time.Second): + t.Fatal("timeout waiting for panic recovery") + } + + // Verify error was sent back on the parent's error channel. + select { + case err := <-errChan: + require.Error(t, err) + require.Contains(t, err.Error(), "panic while") + require.Contains(t, err.Error(), "parent job panic") + case <-time.After(time.Second): + t.Fatal("timeout waiting for error on parent") + } + + // The child job should now be unblocked because SignalDependents + // was called during panic recovery. + select { + case err := <-childDone: + // Child should complete without error (or with nil if + // parent jobs are now empty). + require.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("child job still blocked - SignalDependents " + + "did not unblock waiting jobs") + } + + // Clean up the child job. The parent job was already completed by + // finalizeGossipProcessing. + ctx.gossiper.vb.CompleteJob() +} + +// TestRecoverGossipPanicNilJobID verifies that panic recovery works correctly +// when jobID is nil (e.g., for AnnounceSignatures which bypass the validation +// barrier). +func TestRecoverGossipPanicNilJobID(t *testing.T) { + t.Parallel() + + ctx, err := createTestCtx(t, proofMatureDelta, false) + require.NoError(t, err) + + // Create an announce signatures message (these bypass validation + // barrier and thus have nil jobID in the recovery path). + annSigs := &lnwire.AnnounceSignatures1{ + ShortChannelID: lnwire.NewShortChanIDFromInt(12345), + } + + errChan := make(chan error, 1) + nMsg := &networkMsg{ + msg: annSigs, + peer: &mockPeer{ + remoteKeyPub1, nil, nil, atomic.Bool{}, + }, + err: errChan, + } + + // Call finalizeGossipProcessing with nil jobID (simulating the + // AnnounceSignatures serial processing path). + panicked := make(chan struct{}) + go func() { + defer ctx.gossiper.finalizeGossipProcessing( + context.Background(), "processing", nMsg, nil, + ) + defer close(panicked) + + panic("announce signatures panic") + }() + + // Wait for panic recovery. + select { + case <-panicked: + case <-time.After(time.Second): + t.Fatal("timeout waiting for panic recovery") + } + + // Verify error was sent back. + select { + case err := <-errChan: + require.Error(t, err) + require.Contains(t, err.Error(), "panic while") + require.Contains(t, err.Error(), "announce signatures panic") + case <-time.After(time.Second): + t.Fatal("timeout waiting for error") + } +} From a0be1c926b75204a732078bded95f8d41c2e8e2d Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Mon, 29 Dec 2025 14:44:23 -0800 Subject: [PATCH 052/134] discovery: add panic recovery for serial announce signatures processing In this commit, we extend the panic recovery mechanism to cover the serial processing path for AnnounceSignatures1 messages. Unlike other gossip messages which are processed in parallel goroutines, announcement signatures are processed serially in the main networkHandler loop. A panic during this serial processing would previously crash the entire gossiper. This change wraps the processing in an anonymous function with a deferred panic recovery, ensuring resilience without changing the serial processing semantics. Since AnnounceSignatures bypass the validation barrier, we pass nil for the jobID parameter. (cherry picked from commit bcb65f5ac73e278fc6c69c82faa49a0151e1aaa7) --- discovery/gossiper.go | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/discovery/gossiper.go b/discovery/gossiper.go index 4c5044e29..d62d66991 100644 --- a/discovery/gossiper.go +++ b/discovery/gossiper.go @@ -1517,19 +1517,33 @@ func (d *AuthenticatedGossiper) networkHandler(ctx context.Context) { // Channel announcement signatures are amongst the only // messages that we'll process serially. case *lnwire.AnnounceSignatures1: - emittedAnnouncements, _ := d.processNetworkAnnouncement( - ctx, announcement, - ) - log.Debugf("Processed network message %s, "+ - "returned len(announcements)=%v", - announcement.msg.MsgType(), - len(emittedAnnouncements)) - - if emittedAnnouncements != nil { - announcements.AddMsgs( - emittedAnnouncements..., + // Process in an anonymous function so we can + // recover from any panics without crashing the + // main networkHandler goroutine. We pass nil + // for jobID since AnnounceSignatures bypass the + // validation barrier. + func() { + defer d.finalizeGossipProcessing( + ctx, "processing", + announcement, nil, ) - } + + //nolint:ll + emittedAnnouncements, _ := d.processNetworkAnnouncement( + ctx, announcement, + ) + log.Debugf("Processed network "+ + "message %s, returned "+ + "len(announcements)=%v", + announcement.msg.MsgType(), + len(emittedAnnouncements)) + + if emittedAnnouncements != nil { + announcements.AddMsgs( + emittedAnnouncements..., + ) + } + }() continue } From 5cf38edc629c1cc671fdbcb5460a7b3118744847 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Tue, 13 Jan 2026 08:17:50 +0100 Subject: [PATCH 053/134] docs/release-notes: add release notes (cherry picked from commit 7da41cb36745c9b888f6aefe2b9e3e1b25cf9e7b) --- docs/release-notes/release-notes-0.20.1.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/release-notes/release-notes-0.20.1.md b/docs/release-notes/release-notes-0.20.1.md index e726406a1..0d39f503e 100644 --- a/docs/release-notes/release-notes-0.20.1.md +++ b/docs/release-notes/release-notes-0.20.1.md @@ -86,6 +86,13 @@ # Improvements ## Functional Updates +* [Added panic recovery](https://github.com/lightningnetwork/lnd/pull/10470) to + the gossiper's message processing goroutines. This increases the robustness + of the gossiper subsystem by allowing it to continue operating even if a + logic error causes a panic during message processing. The recovery mechanism + ensures dependencies are properly freed and logs the panic trace for + debugging. + ## RPC Updates * The `EstimateRouteFee` RPC now implements an [LSP detection From 56829a9c8af489a8c1ed7efb109ffbeb3ad53b09 Mon Sep 17 00:00:00 2001 From: ziggie Date: Tue, 13 Jan 2026 13:01:19 +0100 Subject: [PATCH 054/134] discovery: fix new usetesting linter issues (cherry picked from commit 35260fc4a7bcc0c25e9a34f2d1aa8e9498bdc889) --- discovery/gossiper_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/discovery/gossiper_test.go b/discovery/gossiper_test.go index 5738d6a0f..2776d4b3a 100644 --- a/discovery/gossiper_test.go +++ b/discovery/gossiper_test.go @@ -5006,7 +5006,7 @@ func TestRecoverGossipPanic(t *testing.T) { panicked := make(chan struct{}) go func() { defer ctx.gossiper.finalizeGossipProcessing( - context.Background(), "testing", + t.Context(), "testing", nMsg, jobIDRef, ) defer close(panicked) @@ -5069,7 +5069,7 @@ func TestRecoverGossipPanicBlockedErrorChannel(t *testing.T) { panicked := make(chan struct{}) go func() { defer ctx.gossiper.finalizeGossipProcessing( - context.Background(), "testing", nMsg, &jobID, + t.Context(), "testing", nMsg, &jobID, ) defer close(panicked) @@ -5141,7 +5141,7 @@ func TestRecoverGossipPanicSignalsDependents(t *testing.T) { panicked := make(chan struct{}) go func() { defer ctx.gossiper.finalizeGossipProcessing( - context.Background(), "testing", nMsg, &parentJobID, + t.Context(), "testing", nMsg, &parentJobID, ) defer close(panicked) @@ -5211,7 +5211,7 @@ func TestRecoverGossipPanicNilJobID(t *testing.T) { panicked := make(chan struct{}) go func() { defer ctx.gossiper.finalizeGossipProcessing( - context.Background(), "processing", nMsg, nil, + t.Context(), "processing", nMsg, nil, ) defer close(panicked) From a7ca3387204a1d915e8c1274e8c94c1c5037f355 Mon Sep 17 00:00:00 2001 From: ziggie Date: Mon, 12 Jan 2026 20:08:36 +0100 Subject: [PATCH 055/134] build: update CI+release version to Go 1.25.5 This updates the toolchain which we require to build the LND executable. (cherry picked from commit 61d82fd1f2c0dca0eea50dd0aac8ac513f10f1d6) --- .github/actions/setup-go/action.yml | 2 +- .github/workflows/main.yml | 2 +- .github/workflows/release.yaml | 2 +- .golangci.yml | 2 +- Dockerfile | 2 +- Makefile | 2 +- dev.Dockerfile | 2 +- docker/btcd/Dockerfile | 2 +- lnrpc/Dockerfile | 2 +- lnrpc/gen_protos_docker.sh | 2 +- make/builder.Dockerfile | 2 +- tools/Dockerfile | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/actions/setup-go/action.yml b/.github/actions/setup-go/action.yml index 141097edc..09f47d769 100644 --- a/.github/actions/setup-go/action.yml +++ b/.github/actions/setup-go/action.yml @@ -52,7 +52,7 @@ runs: # The key is used to create and later look up the cache. It's made of # four parts: # - The base part is made from the OS name, Go version and a - # job-specified key prefix. Example: `linux-go-1.25.3-unit-test-`. + # job-specified key prefix. Example: `linux-go-1.25.5-unit-test-`. # It ensures that a job running on Linux with Go 1.25 only looks for # caches from the same environment. # - The unique part is the `hashFiles('**/go.sum')`, which calculates a diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d92e9d2c8..f7ec9f8e2 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -40,7 +40,7 @@ env: # If you change this please also update GO_VERSION in Makefile (then run # `make lint` to see where else it needs to be updated as well). - GO_VERSION: 1.25.3 + GO_VERSION: 1.25.5 jobs: static-checks: diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 64b768f2f..012a71628 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -12,7 +12,7 @@ defaults: env: # If you change this please also update GO_VERSION in Makefile (then run # `make lint` to see where else it needs to be updated as well). - GO_VERSION: 1.25.3 + GO_VERSION: 1.25.5 jobs: ######################## diff --git a/.golangci.yml b/.golangci.yml index a60ea9320..bcbf5e026 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,7 +1,7 @@ run: # If you change this please also update GO_VERSION in Makefile (then run # `make lint` to see where else it needs to be updated as well). - go: "1.25.3" + go: "1.25.5" # Abort after 10 minutes. timeout: 10m diff --git a/Dockerfile b/Dockerfile index e28726dc3..a152d9184 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # If you change this please also update GO_VERSION in Makefile (then run # `make lint` to see where else it needs to be updated as well). -FROM golang:1.25.3-alpine as builder +FROM golang:1.25.5-alpine as builder # Force Go to use the cgo based DNS resolver. This is required to ensure DNS # queries required to connect to linked containers succeed. diff --git a/Makefile b/Makefile index 0b18ac402..6d0faf00b 100644 --- a/Makefile +++ b/Makefile @@ -28,7 +28,7 @@ ACTIVE_GO_VERSION_MINOR := $(shell echo $(ACTIVE_GO_VERSION) | cut -d. -f2) # GO_VERSION is the Go version used for the release build, docker files, and # GitHub Actions. This is the reference version for the project. All other Go # versions are checked against this version. -GO_VERSION = 1.25.3 +GO_VERSION = 1.25.5 GOBUILD := $(GOCC) build -v GOINSTALL := $(GOCC) install -v diff --git a/dev.Dockerfile b/dev.Dockerfile index 41a9b66e2..4d681d8de 100644 --- a/dev.Dockerfile +++ b/dev.Dockerfile @@ -1,6 +1,6 @@ # If you change this please also update GO_VERSION in Makefile (then run # `make lint` to see where else it needs to be updated as well). -FROM golang:1.25.3-alpine AS builder +FROM golang:1.25.5-alpine AS builder LABEL maintainer="Olaoluwa Osuntokun " diff --git a/docker/btcd/Dockerfile b/docker/btcd/Dockerfile index 699c3466c..97ab32d36 100644 --- a/docker/btcd/Dockerfile +++ b/docker/btcd/Dockerfile @@ -1,6 +1,6 @@ # If you change this please also update GO_VERSION in Makefile (then run # `make lint` to see where else it needs to be updated as well). -FROM golang:1.25.3-alpine as builder +FROM golang:1.25.5-alpine as builder LABEL maintainer="Olaoluwa Osuntokun " diff --git a/lnrpc/Dockerfile b/lnrpc/Dockerfile index 431eeb20b..680a774e8 100644 --- a/lnrpc/Dockerfile +++ b/lnrpc/Dockerfile @@ -1,6 +1,6 @@ # If you change this please also update GO_VERSION in Makefile (then run # `make lint` to see where else it needs to be updated as well). -FROM golang:1.25.3-bookworm +FROM golang:1.25.5-bookworm RUN apt-get update && apt-get install -y \ git \ diff --git a/lnrpc/gen_protos_docker.sh b/lnrpc/gen_protos_docker.sh index 604c3bbc1..68c65581a 100755 --- a/lnrpc/gen_protos_docker.sh +++ b/lnrpc/gen_protos_docker.sh @@ -6,7 +6,7 @@ set -e DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # golang docker image version used in this script. -GO_IMAGE=docker.io/library/golang:1.25.3-alpine +GO_IMAGE=docker.io/library/golang:1.25.5-alpine PROTOBUF_VERSION=$(docker run --rm -v $DIR/../:/lnd -w /lnd $GO_IMAGE \ go list -f '{{.Version}}' -m google.golang.org/protobuf) diff --git a/make/builder.Dockerfile b/make/builder.Dockerfile index c85ddbdd1..99d4aec55 100644 --- a/make/builder.Dockerfile +++ b/make/builder.Dockerfile @@ -1,6 +1,6 @@ # If you change this please also update GO_VERSION in Makefile (then run # `make lint` to see where else it needs to be updated as well). -FROM golang:1.25.3-bookworm +FROM golang:1.25.5-bookworm MAINTAINER Olaoluwa Osuntokun diff --git a/tools/Dockerfile b/tools/Dockerfile index 9d9f13f07..7fa3270eb 100644 --- a/tools/Dockerfile +++ b/tools/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.25.3 +FROM golang:1.25.5 RUN apt-get update && apt-get install -y git ENV GOCACHE=/tmp/build/.cache From 8565d12e40b12aff82d098d2ff1d35c5a2aeb422 Mon Sep 17 00:00:00 2001 From: ziggie Date: Mon, 12 Jan 2026 20:09:34 +0100 Subject: [PATCH 056/134] mod: update the minimum go version to 1.24.11 We update the minimum required go version to build the LND exectuable to the latest minor release of go 1.24.11. (cherry picked from commit e26a114cdcf0c5e409b2df3ed6d4bd508193671c) --- cert/go.mod | 2 +- clock/go.mod | 2 +- docs/INSTALL.md | 18 +++++++++--------- fn/go.mod | 2 +- go.mod | 2 +- healthcheck/go.mod | 2 +- kvdb/go.mod | 2 +- queue/go.mod | 2 +- sqldb/go.mod | 2 +- ticker/go.mod | 2 +- tlv/go.mod | 2 +- tools/go.mod | 2 +- tools/linters/go.mod | 2 +- tor/go.mod | 2 +- 14 files changed, 22 insertions(+), 22 deletions(-) diff --git a/cert/go.mod b/cert/go.mod index 24498a3fe..4dc5f2a78 100644 --- a/cert/go.mod +++ b/cert/go.mod @@ -1,6 +1,6 @@ module github.com/lightningnetwork/lnd/cert -go 1.19 +go 1.24.11 require github.com/stretchr/testify v1.8.2 diff --git a/clock/go.mod b/clock/go.mod index b54398ffc..1c176ad4a 100644 --- a/clock/go.mod +++ b/clock/go.mod @@ -1,6 +1,6 @@ module github.com/lightningnetwork/lnd/clock -go 1.19 +go 1.24.11 require github.com/stretchr/testify v1.8.2 diff --git a/docs/INSTALL.md b/docs/INSTALL.md index bc1a64d2a..a7714c41b 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -93,7 +93,7 @@ following build dependencies are required: ### Installing Go -`lnd` is written in Go, with a minimum version of `1.24.9` (or, in case this +`lnd` is written in Go, with a minimum version of `1.24.11` (or, in case this document gets out of date, whatever the Go version in the main `go.mod` file requires). To install, run one of the following commands for your OS: @@ -101,15 +101,15 @@ requires). To install, run one of the following commands for your OS: Linux (x86-64) ``` - wget https://dl.google.com/go/go1.24.9.linux-amd64.tar.gz - echo "5b7899591c2dd6e9da1809fde4a2fad842c45d3f6b9deb235ba82216e31e34a6 go1.24.9.linux-amd64.tar.gz" | sha256sum --check + wget https://dl.google.com/go/go1.24.11.linux-amd64.tar.gz + echo "bceca00afaac856bc48b4cc33db7cd9eb383c81811379faed3bdbc80edb0af65 go1.24.11.linux-amd64.tar.gz" | sha256sum --check ``` - The command above should output `go1.24.9.linux-amd64.tar.gz: OK`. If it + The command above should output `go1.24.11.linux-amd64.tar.gz: OK`. If it doesn't, then the target REPO HAS BEEN MODIFIED, and you shouldn't install this version of Go. If it matches, then proceed to install Go: ``` - sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.24.9.linux-amd64.tar.gz + sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.24.11.linux-amd64.tar.gz export PATH=$PATH:/usr/local/go/bin ``` @@ -118,15 +118,15 @@ requires). To install, run one of the following commands for your OS: Linux (ARMv6) ``` - wget https://dl.google.com/go/go1.24.9.linux-armv6l.tar.gz - echo "39dafc8e7e5e455995f87e1ffc6b0892302ea519c1f0e59c9e2e0fda41b8aa56 go1.24.9.linux-armv6l.tar.gz" | sha256sum --check + wget https://dl.google.com/go/go1.24.11.linux-armv6l.tar.gz + echo "24d712a7e8ea2f429c05bc67287249e0291f2fe0ea6d6ff268f11b7343ad0f47 go1.24.11.linux-armv6l.tar.gz" | sha256sum --check ``` - The command above should output `go1.24.9.linux-armv6l.tar.gz: OK`. If it + The command above should output `go1.24.11.linux-armv6l.tar.gz: OK`. If it isn't, then the target REPO HAS BEEN MODIFIED, and you shouldn't install this version of Go. If it matches, then proceed to install Go: ``` - sudo rm -rf /usr/local/go && tar -C /usr/local -xzf go1.24.9.linux-armv6l.tar.gz + sudo rm -rf /usr/local/go && tar -C /usr/local -xzf go1.24.11.linux-armv6l.tar.gz export PATH=$PATH:/usr/local/go/bin ``` diff --git a/fn/go.mod b/fn/go.mod index abf0cdf4c..adb56f814 100644 --- a/fn/go.mod +++ b/fn/go.mod @@ -1,6 +1,6 @@ module github.com/lightningnetwork/lnd/fn/v2 -go 1.23 +go 1.24.11 require ( github.com/stretchr/testify v1.8.1 diff --git a/go.mod b/go.mod index d48da7235..ac06f8b6e 100644 --- a/go.mod +++ b/go.mod @@ -220,6 +220,6 @@ replace google.golang.org/protobuf => github.com/lightninglabs/protobuf-go-hex-d // If you change this please also update docs/INSTALL.md and GO_VERSION in // Makefile (then run `make lint` to see where else it needs to be updated as // well). -go 1.24.9 +go 1.24.11 retract v0.0.2 diff --git a/healthcheck/go.mod b/healthcheck/go.mod index a9f846091..4c562bdd6 100644 --- a/healthcheck/go.mod +++ b/healthcheck/go.mod @@ -24,4 +24,4 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -go 1.24.9 +go 1.24.11 diff --git a/kvdb/go.mod b/kvdb/go.mod index 8171f7e06..4c2dc49a2 100644 --- a/kvdb/go.mod +++ b/kvdb/go.mod @@ -147,4 +147,4 @@ replace github.com/ulikunitz/xz => github.com/ulikunitz/xz v0.5.11 // https://deps.dev/advisory/OSV/GO-2021-0053?from=%2Fgo%2Fgithub.com%252Fgogo%252Fprotobuf%2Fv1.3.1 replace github.com/gogo/protobuf => github.com/gogo/protobuf v1.3.2 -go 1.24.9 +go 1.24.11 diff --git a/queue/go.mod b/queue/go.mod index 58267e276..590bd7d68 100644 --- a/queue/go.mod +++ b/queue/go.mod @@ -4,4 +4,4 @@ require github.com/lightningnetwork/lnd/ticker v1.0.0 replace github.com/lightningnetwork/lnd/ticker v1.0.0 => ../ticker -go 1.19 +go 1.24.11 diff --git a/sqldb/go.mod b/sqldb/go.mod index 33a497e33..6331fa324 100644 --- a/sqldb/go.mod +++ b/sqldb/go.mod @@ -75,4 +75,4 @@ require ( modernc.org/token v1.1.0 // indirect ) -go 1.24.9 +go 1.24.11 diff --git a/ticker/go.mod b/ticker/go.mod index 3017b2139..d78f913a3 100644 --- a/ticker/go.mod +++ b/ticker/go.mod @@ -1,3 +1,3 @@ module github.com/lightningnetwork/lnd/ticker -go 1.19 +go 1.24.11 diff --git a/tlv/go.mod b/tlv/go.mod index 44953d0c4..dd1302d75 100644 --- a/tlv/go.mod +++ b/tlv/go.mod @@ -22,4 +22,4 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -go 1.24.9 +go 1.24.11 diff --git a/tools/go.mod b/tools/go.mod index 72af0506c..5aa875a77 100644 --- a/tools/go.mod +++ b/tools/go.mod @@ -1,6 +1,6 @@ module github.com/lightningnetwork/lnd/tools -go 1.24.9 +go 1.24.11 require ( github.com/btcsuite/btcd v0.24.2 diff --git a/tools/linters/go.mod b/tools/linters/go.mod index cebb0e7b4..3ee38851f 100644 --- a/tools/linters/go.mod +++ b/tools/linters/go.mod @@ -1,6 +1,6 @@ module github.com/lightningnetwork/lnd/tools/linters -go 1.24.9 +go 1.24.11 require ( github.com/golangci/plugin-module-register v0.1.1 diff --git a/tor/go.mod b/tor/go.mod index fd4c3d429..a67c3ed79 100644 --- a/tor/go.mod +++ b/tor/go.mod @@ -23,4 +23,4 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -go 1.24.9 +go 1.24.11 From c8364afaeb0d24543581ed19eb4b7c897959d5c1 Mon Sep 17 00:00:00 2001 From: ziggie Date: Tue, 13 Jan 2026 17:31:24 +0100 Subject: [PATCH 057/134] fn: fix printf vet check in TestSomeToOkf Go 1.24+ See also https://github.com/golang/go/issues/60529. Now we need to use a constant. (cherry picked from commit 7085f4706d70a26404f145aec18c70bf71954cd9) --- fn/option_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/fn/option_test.go b/fn/option_test.go index 69f6608d3..915110439 100644 --- a/fn/option_test.go +++ b/fn/option_test.go @@ -20,11 +20,11 @@ func TestSomeToOk(t *testing.T) { } func TestSomeToOkf(t *testing.T) { - errStr := "err" - require.Equal(t, Some(1).SomeToOkf(errStr), Ok(1)) + const errFmt = "missing value: %s" + require.Equal(t, Some(1).SomeToOkf(errFmt, "test"), Ok(1)) require.Equal( - t, None[uint8]().SomeToOkf(errStr), - Err[uint8](fmt.Errorf(errStr)), + t, None[uint8]().SomeToOkf(errFmt, "test"), + Err[uint8](fmt.Errorf(errFmt, "test")), ) } From 55807ac4d0a91d6a24deb7b828fb2fafa2a79762 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Wed, 3 Sep 2025 17:43:36 -0700 Subject: [PATCH 058/134] lnwallet: add new helper functions to scale confirmations based on amt --- lnwallet/confscale.go | 58 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 lnwallet/confscale.go diff --git a/lnwallet/confscale.go b/lnwallet/confscale.go new file mode 100644 index 000000000..6e2b010e6 --- /dev/null +++ b/lnwallet/confscale.go @@ -0,0 +1,58 @@ +package lnwallet + +import ( + "github.com/btcsuite/btcd/btcutil" + "github.com/lightningnetwork/lnd/lnwire" +) + +const ( + // minRequiredConfs is the minimum number of confirmations we'll + // require for channel operations. + minRequiredConfs = 1 + + // maxRequiredConfs is the maximum number of confirmations we'll + // require for channel operations. + maxRequiredConfs = 6 + + // maxChannelSize is the maximum expected channel size in satoshis. + // This matches MaxBtcFundingAmount (0.16777215 BTC). + maxChannelSize = 16777215 +) + +// ScaleNumConfs returns a linearly scaled number of confirmations based on the +// provided channel amount and push amount (for funding transactions). The push +// amount represents additional risk when receiving funds. +func ScaleNumConfs(chanAmt btcutil.Amount, pushAmt lnwire.MilliSatoshi) uint16 { + // For wumbo channels, always require maximum confirmations. + if chanAmt > maxChannelSize { + return maxRequiredConfs + } + + // Calculate total stake: channel amount + push amount. The push amount + // represents value at risk for the receiver. + maxChannelSizeMsat := lnwire.NewMSatFromSatoshis(maxChannelSize) + stake := lnwire.NewMSatFromSatoshis(chanAmt) + pushAmt + + // Scale confirmations linearly based on stake. + conf := uint64(maxRequiredConfs) * uint64(stake) / + uint64(maxChannelSizeMsat) + + // Bound the result between minRequiredConfs and maxRequiredConfs. + if conf < minRequiredConfs { + conf = minRequiredConfs + } + if conf > maxRequiredConfs { + conf = maxRequiredConfs + } + + return uint16(conf) +} + +// FundingConfsForAmounts returns the number of confirmations to wait for a +// funding transaction, taking into account both the channel amount and any +// pushed amount (which represents additional risk). +func FundingConfsForAmounts(chanAmt btcutil.Amount, + pushAmt lnwire.MilliSatoshi) uint16 { + + return ScaleNumConfs(chanAmt, pushAmt) +} From 8a38c866285f7b59b7f883d893f8ec3fa7b18b17 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Wed, 3 Sep 2025 17:44:03 -0700 Subject: [PATCH 059/134] server: use new FundingConfsForAmounts helper func --- server.go | 44 +++++++++++--------------------------------- 1 file changed, 11 insertions(+), 33 deletions(-) diff --git a/server.go b/server.go index 3f38e8855..b897d4945 100644 --- a/server.go +++ b/server.go @@ -1468,16 +1468,6 @@ func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr, DefaultMinHtlcIn: cc.MinHtlcIn, NumRequiredConfs: func(chanAmt btcutil.Amount, pushAmt lnwire.MilliSatoshi) uint16 { - // For large channels we increase the number - // of confirmations we require for the - // channel to be considered open. As it is - // always the responder that gets to choose - // value, the pushAmt is value being pushed - // to us. This means we have more to lose - // in the case this gets re-orged out, and - // we will require more confirmations before - // we consider it open. - // In case the user has explicitly specified // a default value for the number of // confirmations, we use it. @@ -1486,29 +1476,17 @@ func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr, return defaultConf } - minConf := uint64(3) - maxConf := uint64(6) - - // If this is a wumbo channel, then we'll require the - // max amount of confirmations. - if chanAmt > MaxFundingAmount { - return uint16(maxConf) - } - - // If not we return a value scaled linearly - // between 3 and 6, depending on channel size. - // TODO(halseth): Use 1 as minimum? - maxChannelSize := uint64( - lnwire.NewMSatFromSatoshis(MaxFundingAmount)) - stake := lnwire.NewMSatFromSatoshis(chanAmt) + pushAmt - conf := maxConf * uint64(stake) / maxChannelSize - if conf < minConf { - conf = minConf - } - if conf > maxConf { - conf = maxConf - } - return uint16(conf) + // Otherwise, scale the number of confirmations based on + // the channel amount and push amount. For large + // channels we increase the number of + // confirmations we require for the channel to be + // considered open. As it is always the + // responder that gets to choose value, the + // pushAmt is value being pushed to us. This + // means we have more to lose in the case this + // gets re-orged out, and we will require more + // confirmations before we consider it open. + return lnwallet.FundingConfsForAmounts(chanAmt, pushAmt) }, RequiredRemoteDelay: func(chanAmt btcutil.Amount) uint16 { // We scale the remote CSV delay (the time the From e5c5011900f25cff8a445d5294088fb9835d5900 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Wed, 3 Sep 2025 17:44:32 -0700 Subject: [PATCH 060/134] lnwallet: define helper func to coop close conf scaling We have two versions: for itests, we just use one conf, but in prod, we'll scale the number of confirmations. --- lnwallet/confscale_integration.go | 13 +++++++++++++ lnwallet/confscale_prod.go | 25 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 lnwallet/confscale_integration.go create mode 100644 lnwallet/confscale_prod.go diff --git a/lnwallet/confscale_integration.go b/lnwallet/confscale_integration.go new file mode 100644 index 000000000..4e78b968a --- /dev/null +++ b/lnwallet/confscale_integration.go @@ -0,0 +1,13 @@ +//go:build integration +// +build integration + +package lnwallet + +import "github.com/btcsuite/btcd/btcutil" + +// CloseConfsForCapacity returns the number of confirmations to wait +// before signaling a cooperative close. Under integration tests, we +// always return 1 to keep tests fast and deterministic. +func CloseConfsForCapacity(capacity btcutil.Amount) uint32 { //nolint:revive + return 1 +} diff --git a/lnwallet/confscale_prod.go b/lnwallet/confscale_prod.go new file mode 100644 index 000000000..898810739 --- /dev/null +++ b/lnwallet/confscale_prod.go @@ -0,0 +1,25 @@ +//go:build !integration +// +build !integration + +package lnwallet + +import "github.com/btcsuite/btcd/btcutil" + +// CloseConfsForCapacity returns the number of confirmations to wait before +// signaling a channel close, scaled by channel capacity. This is used for both +// cooperative and force closes. We enforce a minimum of 3 confirmations to +// provide better reorg protection, even for small channels. +func CloseConfsForCapacity(capacity btcutil.Amount) uint32 { + // For cooperative closes, we don't have a push amount to consider, + // so we pass 0 for the pushAmt parameter. + scaledConfs := uint32(ScaleNumConfs(capacity, 0)) + + // Enforce a minimum of 3 confirmations for reorg safety. + // This protects against shallow reorgs which are more common. + const minCloseConfs = 3 + if scaledConfs < minCloseConfs { + return minCloseConfs + } + + return scaledConfs +} From bd6fa84d8d7018d2a5638c8ae46d3adf83d13e19 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Wed, 3 Sep 2025 17:44:40 -0700 Subject: [PATCH 061/134] lnwallet: add tests for new conf scaling helper funcs --- lnwallet/confscale_test.go | 340 +++++++++++++++++++++++++++++++++++++ 1 file changed, 340 insertions(+) create mode 100644 lnwallet/confscale_test.go diff --git a/lnwallet/confscale_test.go b/lnwallet/confscale_test.go new file mode 100644 index 000000000..53165fc23 --- /dev/null +++ b/lnwallet/confscale_test.go @@ -0,0 +1,340 @@ +package lnwallet + +import ( + "testing" + + "github.com/btcsuite/btcd/btcutil" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/stretchr/testify/require" + "pgregory.net/rapid" +) + +// TestScaleNumConfsProperties tests various properties that ScaleNumConfs +// should satisfy using property-based testing. +func TestScaleNumConfsProperties(t *testing.T) { + t.Parallel() + + // The result should always be bounded between the minimum and maximum + // number of confirmations regardless of input values. + t.Run("bounded_result", func(t *testing.T) { + rapid.Check(t, func(t *rapid.T) { + // Generate random channel amount and push amount. + chanAmt := rapid.Uint64Range( + 0, maxChannelSize*10, + ).Draw(t, "chanAmt") + pushAmtSats := rapid.Uint64Range( + 0, chanAmt, + ).Draw(t, "pushAmtSats") + pushAmt := lnwire.NewMSatFromSatoshis( + btcutil.Amount(pushAmtSats), + ) + + result := ScaleNumConfs( + btcutil.Amount(chanAmt), pushAmt, + ) + + // Check bounds + require.GreaterOrEqual( + t, result, uint16(minRequiredConfs), + "result should be >= minRequiredConfs", + ) + require.LessOrEqual( + t, result, uint16(maxRequiredConfs), + "result should be <= maxRequiredConfs", + ) + }) + }) + + // Larger channel amounts and push amounts should require equal or more + // confirmations, ensuring the function is monotonically increasing. + t.Run("monotonicity", func(t *testing.T) { + rapid.Check(t, func(t *rapid.T) { + // Generate two channel amounts where amt1 <= amt2. + amt1 := rapid.Uint64Range( + 0, maxChannelSize, + ).Draw(t, "amt1") + amt2 := rapid.Uint64Range( + amt1, maxChannelSize, + ).Draw(t, "amt2") + + // Generate push amounts proportional to channel size. + pushAmt1Sats := rapid.Uint64Range( + 0, amt1, + ).Draw(t, "pushAmt1") + pushAmt2Sats := rapid.Uint64Range( + pushAmt1Sats, amt2, + ).Draw(t, "pushAmt2") + + pushAmt1 := lnwire.NewMSatFromSatoshis( + btcutil.Amount(pushAmt1Sats), + ) + pushAmt2 := lnwire.NewMSatFromSatoshis( + btcutil.Amount(pushAmt2Sats), + ) + + confs1 := ScaleNumConfs(btcutil.Amount(amt1), pushAmt1) + confs2 := ScaleNumConfs(btcutil.Amount(amt2), pushAmt2) + + // Larger or equal stake should require equal or more + // confirmations. + require.GreaterOrEqual( + t, confs2, confs1, + "larger amount should require equal or "+ + "more confirmations", + ) + }) + }) + + // Wumbo channels (those exceeding the max standard channel size) should + // always require the maximum number of confirmations for safety. + t.Run("wumbo_max_confs", func(t *testing.T) { + rapid.Check(t, func(t *rapid.T) { + // Generate wumbo channel amount (above maxChannelSize). + wumboAmt := rapid.Uint64Range( + maxChannelSize+1, maxChannelSize*100, + ).Draw(t, "wumboAmt") + pushAmtSats := rapid.Uint64Range( + 0, wumboAmt, + ).Draw(t, "pushAmtSats") + pushAmt := lnwire.NewMSatFromSatoshis( + btcutil.Amount(pushAmtSats), + ) + + result := ScaleNumConfs( + btcutil.Amount(wumboAmt), pushAmt, + ) + + require.Equal( + t, uint16(maxRequiredConfs), result, + "wumbo channels should always get "+ + "max confirmations", + ) + }) + }) + + // Zero channel amounts should always result in the minimum number of + // confirmations since there's no value at risk. + t.Run("zero_gets_min", func(t *testing.T) { + result := ScaleNumConfs(0, 0) + require.Equal( + t, uint16(minRequiredConfs), result, + "zero amount should get minimum confirmations", + ) + }) + + // The function should be deterministic, always returning the same + // output for the same input values. + t.Run("determinism", func(t *testing.T) { + rapid.Check(t, func(t *rapid.T) { + chanAmt := rapid.Uint64Range( + 0, maxChannelSize*2, + ).Draw(t, "chanAmt") + pushAmtSats := rapid.Uint64Range( + 0, chanAmt, + ).Draw(t, "pushAmtSats") + pushAmt := lnwire.NewMSatFromSatoshis( + btcutil.Amount(pushAmtSats), + ) + + // Call multiple times with same inputs. + result1 := ScaleNumConfs( + btcutil.Amount(chanAmt), pushAmt, + ) + result2 := ScaleNumConfs( + btcutil.Amount(chanAmt), pushAmt, + ) + result3 := ScaleNumConfs( + btcutil.Amount(chanAmt), pushAmt, + ) + + require.Equal( + t, result1, result2, + "function should be deterministic", + ) + require.Equal( + t, result2, result3, + "function should be deterministic", + ) + }) + }) + + // Adding a push amount to a channel should require equal or more + // confirmations compared to the same channel without a push amount. + t.Run("push_amount_effect", func(t *testing.T) { + rapid.Check(t, func(t *rapid.T) { + // Fix channel amount, vary push amount + chanAmt := rapid.Uint64Range( + 1, maxChannelSize, + ).Draw(t, "chanAmt") + pushAmt1Sats := rapid.Uint64Range( + 0, chanAmt/2, + ).Draw(t, "pushAmt1") + pushAmt2Sats := rapid.Uint64Range( + pushAmt1Sats, chanAmt, + ).Draw(t, "pushAmt2") + + pushAmt1 := lnwire.NewMSatFromSatoshis( + btcutil.Amount(pushAmt1Sats), + ) + pushAmt2 := lnwire.NewMSatFromSatoshis( + btcutil.Amount(pushAmt2Sats), + ) + + confs1 := ScaleNumConfs( + btcutil.Amount(chanAmt), pushAmt1, + ) + confs2 := ScaleNumConfs( + btcutil.Amount(chanAmt), pushAmt2, + ) + + // More push amount should require equal or more + // confirmations. + require.GreaterOrEqual( + t, confs2, confs1, + "larger push amount should "+ + "require equal or more confirmations", + ) + }) + }) +} + +// TestScaleNumConfsKnownValues tests ScaleNumConfs with specific known values +// to ensure the scaling formula works as expected. +func TestScaleNumConfsKnownValues(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + chanAmt btcutil.Amount + pushAmt lnwire.MilliSatoshi + expected uint16 + }{ + { + name: "zero amounts", + chanAmt: 0, + pushAmt: 0, + expected: minRequiredConfs, + }, + { + name: "tiny channel", + chanAmt: 1000, + pushAmt: 0, + expected: minRequiredConfs, + }, + { + name: "small channel no push", + chanAmt: 100_000, + pushAmt: 0, + expected: minRequiredConfs, + }, + { + name: "half max channel no push", + chanAmt: maxChannelSize / 2, + pushAmt: 0, + expected: 2, + }, + { + name: "max channel no push", + chanAmt: maxChannelSize, + pushAmt: 0, + expected: maxRequiredConfs, + }, + { + name: "wumbo channel", + chanAmt: maxChannelSize * 2, + pushAmt: 0, + expected: maxRequiredConfs, + }, + { + name: "small channel with push", + chanAmt: 100_000, + pushAmt: lnwire.NewMSatFromSatoshis(50_000), + expected: minRequiredConfs, + }, + { + name: "medium channel with significant push", + chanAmt: maxChannelSize / 4, + pushAmt: lnwire.NewMSatFromSatoshis( + maxChannelSize / 4, + ), + expected: 2, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + result := ScaleNumConfs(tc.chanAmt, tc.pushAmt) + + require.Equal( + t, tc.expected, result, + "chanAmt=%d, pushAmt=%d", tc.chanAmt, + tc.pushAmt, + ) + }) + } +} + +// TestFundingConfsForAmounts verifies that FundingConfsForAmounts is a simple +// wrapper around ScaleNumConfs. +func TestFundingConfsForAmounts(t *testing.T) { + t.Parallel() + + rapid.Check(t, func(t *rapid.T) { + chanAmt := rapid.Uint64Range( + 0, maxChannelSize*2, + ).Draw(t, "chanAmt") + pushAmtSats := rapid.Uint64Range( + 0, chanAmt, + ).Draw(t, "pushAmtSats") + pushAmt := lnwire.NewMSatFromSatoshis( + btcutil.Amount(pushAmtSats), + ) + + // Both functions should return the same result. + scaleResult := ScaleNumConfs(btcutil.Amount(chanAmt), pushAmt) + fundingResult := FundingConfsForAmounts( + btcutil.Amount(chanAmt), pushAmt, + ) + + require.Equal( + t, scaleResult, fundingResult, + "FundingConfsForAmounts should return "+ + "same result as ScaleNumConfs", + ) + }) +} + +// TestCloseConfsForCapacity verifies that CloseConfsForCapacity correctly +// wraps ScaleNumConfs with zero push amount and enforces a minimum of 3 +// confirmations for reorg safety. +func TestCloseConfsForCapacity(t *testing.T) { + t.Parallel() + + rapid.Check(t, func(t *rapid.T) { + capacity := rapid.Uint64Range( + 0, maxChannelSize*2, + ).Draw(t, "capacity") + + // CloseConfsForCapacity should be equivalent to ScaleNumConfs + // with 0 push, but with a minimum of 3 confirmations enforced + // for reorg safety. + closeConfs := CloseConfsForCapacity(btcutil.Amount(capacity)) + scaleConfs := ScaleNumConfs(btcutil.Amount(capacity), 0) + + // The result should be at least the scaled value, but with a + // minimum of 3 confirmations. + const minCloseConfs = 3 + expectedConfs := uint32(scaleConfs) + if expectedConfs < minCloseConfs { + expectedConfs = minCloseConfs + } + + require.Equal( + t, expectedConfs, closeConfs, + "CloseConfsForCapacity should match "+ + "ScaleNumConfs with 0 push amount, "+ + "but with minimum of 3 confs", + ) + }) +} From d9d2bf466e67f4c2d81a5837d558557dc94a5b18 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Wed, 3 Sep 2025 17:45:31 -0700 Subject: [PATCH 062/134] peer+rpcserver: use new conf scaling for notifications --- peer/brontide.go | 32 ++++++++++++++++++-------------- rpcserver.go | 9 ++++++++- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/peer/brontide.go b/peer/brontide.go index 9191cbb2e..6fbba297d 100644 --- a/peer/brontide.go +++ b/peer/brontide.go @@ -4444,14 +4444,19 @@ func (p *Brontide) finalizeChanClosure(chanCloser *chancloser.ChanCloser) { localOut := chanCloser.LocalCloseOutput() remoteOut := chanCloser.RemoteCloseOutput() auxOut := chanCloser.AuxOutputs() - go WaitForChanToClose( - chanCloser.NegotiationHeight(), notifier, errChan, - &chanPoint, &closingTxid, closingTx.TxOut[0].PkScript, func() { - // Respond to the local subsystem which requested the - // channel closure. - if closeReq != nil { - closeReq.Updates <- &ChannelCloseUpdate{ - ClosingTxid: closingTxid[:], + // Determine the number of confirmations to wait before + // signaling a successful cooperative close, scaled by + // channel capacity (see CloseConfsForCapacity). + numConfs := lnwallet.CloseConfsForCapacity(chanCloser.Channel().Capacity) + + go WaitForChanToClose( + chanCloser.NegotiationHeight(), notifier, errChan, + &chanPoint, &closingTxid, closingTx.TxOut[0].PkScript, numConfs, func() { + // Respond to the local subsystem which requested the + // channel closure. + if closeReq != nil { + closeReq.Updates <- &ChannelCloseUpdate{ + ClosingTxid: closingTxid[:], Success: true, LocalCloseOutput: localOut, RemoteCloseOutput: remoteOut, @@ -4468,16 +4473,15 @@ func (p *Brontide) finalizeChanClosure(chanCloser *chancloser.ChanCloser) { // finally the callback will be executed. If any error is encountered within // the function, then it will be sent over the errChan. func WaitForChanToClose(bestHeight uint32, notifier chainntnfs.ChainNotifier, - errChan chan error, chanPoint *wire.OutPoint, - closingTxID *chainhash.Hash, closeScript []byte, cb func()) { + errChan chan error, chanPoint *wire.OutPoint, + closingTxID *chainhash.Hash, closeScript []byte, numConfs uint32, cb func()) { peerLog.Infof("Waiting for confirmation of close of ChannelPoint(%v) "+ "with txid: %v", chanPoint, closingTxID) - // TODO(roasbeef): add param for num needed confs - confNtfn, err := notifier.RegisterConfirmationsNtfn( - closingTxID, closeScript, 1, bestHeight, - ) + confNtfn, err := notifier.RegisterConfirmationsNtfn( + closingTxID, closeScript, numConfs, bestHeight, + ) if err != nil { if errChan != nil { errChan <- err diff --git a/rpcserver.go b/rpcserver.go index 64eb40fb8..4189b8881 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -2807,9 +2807,16 @@ func (r *rpcServer) CloseChannel(in *lnrpc.CloseChannelRequest, errChan = make(chan error, 1) notifier := r.server.cc.ChainNotifier + + // For force closes, we notify the RPC client immediately after + // 1 confirmation. The actual security-critical confirmation + // waiting is handled by the channel arbitrator. + numConfs := uint32(1) + go peer.WaitForChanToClose( uint32(bestHeight), notifier, errChan, chanPoint, - &closingTxid, closingTx.TxOut[0].PkScript, func() { + &closingTxid, closingTx.TxOut[0].PkScript, numConfs, + func() { // Respond to the local subsystem which // requested the channel closure. updateChan <- &peer.ChannelCloseUpdate{ From a28a09670a736153e0516a06514a2711b654f38b Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Wed, 1 Oct 2025 16:40:13 -0700 Subject: [PATCH 063/134] lncfg: add new dev config option for scaling channel close confs This'll be useful for the set up upcoming itests. --- lncfg/dev.go | 7 +++++++ lncfg/dev_integration.go | 12 ++++++++++++ 2 files changed, 19 insertions(+) diff --git a/lncfg/dev.go b/lncfg/dev.go index f048d69b7..8e0c9dda4 100644 --- a/lncfg/dev.go +++ b/lncfg/dev.go @@ -5,6 +5,7 @@ package lncfg import ( "time" + "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/lnwallet/chanfunding" ) @@ -58,3 +59,9 @@ func (d *DevConfig) GetMaxWaitNumBlocksFundingConf() uint32 { func (d *DevConfig) GetUnsafeConnect() bool { return false } + +// ChannelCloseConfs returns the config value for channel close confirmations +// override, which is always None for production build. +func (d *DevConfig) ChannelCloseConfs() fn.Option[uint32] { + return fn.None[uint32]() +} diff --git a/lncfg/dev_integration.go b/lncfg/dev_integration.go index 8ac85f5d9..b299fb4fc 100644 --- a/lncfg/dev_integration.go +++ b/lncfg/dev_integration.go @@ -5,6 +5,7 @@ package lncfg import ( "time" + "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/lnwallet/chanfunding" ) @@ -27,6 +28,7 @@ type DevConfig struct { UnsafeDisconnect bool `long:"unsafedisconnect" description:"Allows the rpcserver to intentionally disconnect from peers with open channels."` MaxWaitNumBlocksFundingConf uint32 `long:"maxwaitnumblocksfundingconf" description:"Maximum blocks to wait for funding confirmation before discarding non-initiated channels."` UnsafeConnect bool `long:"unsafeconnect" description:"Allow the rpcserver to connect to a peer even if there's already a connection."` + ForceChannelCloseConfs uint32 `long:"force-channel-close-confs" description:"Force a specific number of confirmations for channel closes (dev/test only)"` } // ChannelReadyWait returns the config value `ProcessChannelReadyWait`. @@ -71,3 +73,13 @@ func (d *DevConfig) GetMaxWaitNumBlocksFundingConf() uint32 { func (d *DevConfig) GetUnsafeConnect() bool { return d.UnsafeConnect } + +// ChannelCloseConfs returns the forced confirmation count if set, or None if +// the default behavior should be used. +func (d *DevConfig) ChannelCloseConfs() fn.Option[uint32] { + if d.ForceChannelCloseConfs == 0 { + return fn.None[uint32]() + } + + return fn.Some(d.ForceChannelCloseConfs) +} From 65d53ce8759c0ea783a10e7fca5fc82b1665f62f Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Wed, 1 Oct 2025 16:43:53 -0700 Subject: [PATCH 064/134] multi: add new ChannelCloseConfs param, thread thru as needed In this commit, we add a new param that'll allow us to scale up the number of confirmations before we act on a new close. We'll use this later to improve the current on chain handling logic. --- contractcourt/chain_arbitrator.go | 8 ++++++++ contractcourt/chain_watcher.go | 6 ++++++ peer/brontide.go | 6 ++++++ server.go | 8 +++++--- 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/contractcourt/chain_arbitrator.go b/contractcourt/chain_arbitrator.go index 05eb46a68..b4b2fa26f 100644 --- a/contractcourt/chain_arbitrator.go +++ b/contractcourt/chain_arbitrator.go @@ -230,6 +230,12 @@ type ChainArbitratorConfig struct { // AuxResolver is an optional interface that can be used to modify the // way contracts are resolved. AuxResolver fn.Option[lnwallet.AuxContractResolver] + + // ChannelCloseConfs is an optional override for the number of + // confirmations required for channel closes. When set, this overrides + // the normal capacity-based scaling. This is only available in + // dev/integration builds for testing purposes. + ChannelCloseConfs fn.Option[uint32] } // ChainArbitrator is a sub-system that oversees the on-chain resolution of all @@ -1138,6 +1144,7 @@ func (c *ChainArbitrator) WatchNewChannel(newChan *channeldb.OpenChannel) error extractStateNumHint: lnwallet.GetStateNumHint, auxLeafStore: c.cfg.AuxLeafStore, auxResolver: c.cfg.AuxResolver, + chanCloseConfs: c.cfg.ChannelCloseConfs, }, ) if err != nil { @@ -1315,6 +1322,7 @@ func (c *ChainArbitrator) loadOpenChannels() error { extractStateNumHint: lnwallet.GetStateNumHint, auxLeafStore: c.cfg.AuxLeafStore, auxResolver: c.cfg.AuxResolver, + chanCloseConfs: c.cfg.ChannelCloseConfs, }, ) if err != nil { diff --git a/contractcourt/chain_watcher.go b/contractcourt/chain_watcher.go index 082b47228..a0ebbc64e 100644 --- a/contractcourt/chain_watcher.go +++ b/contractcourt/chain_watcher.go @@ -229,6 +229,12 @@ type chainWatcherConfig struct { // auxResolver is used to supplement contract resolution. auxResolver fn.Option[lnwallet.AuxContractResolver] + + // chanCloseConfs is an optional override for the number of + // confirmations required for channel closes. When set, this overrides + // the normal capacity-based scaling. This is only available in + // dev/integration builds for testing purposes. + chanCloseConfs fn.Option[uint32] } // chainWatcher is a system that's assigned to every active channel. The duty diff --git a/peer/brontide.go b/peer/brontide.go index 6fbba297d..7d69aeefb 100644 --- a/peer/brontide.go +++ b/peer/brontide.go @@ -370,6 +370,12 @@ type Config struct { // closure initiated by the remote peer. CoopCloseTargetConfs uint32 + // ChannelCloseConfs is an optional override for the number of + // confirmations required for channel closes. When set, this overrides + // the normal capacity-based scaling. This is only available in + // dev/integration builds for testing purposes. + ChannelCloseConfs fn.Option[uint32] + // ServerPubKey is the serialized, compressed public key of our lnd node. // It is used to determine which policy (channel edge) to pass to the // ChannelLink. diff --git a/server.go b/server.go index b897d4945..26f54f85f 100644 --- a/server.go +++ b/server.go @@ -1361,9 +1361,10 @@ func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr, return &pc.Incoming }, - AuxLeafStore: implCfg.AuxLeafStore, - AuxSigner: implCfg.AuxSigner, - AuxResolver: implCfg.AuxContractResolver, + AuxLeafStore: implCfg.AuxLeafStore, + AuxSigner: implCfg.AuxSigner, + AuxResolver: implCfg.AuxContractResolver, + ChannelCloseConfs: s.cfg.Dev.ChannelCloseConfs(), }, dbs.ChanStateDB) // Select the configuration and funding parameters for Bitcoin. @@ -4388,6 +4389,7 @@ func (s *server) peerConnected(conn net.Conn, connReq *connmgr.ConnReq, MaxOutgoingCltvExpiry: s.cfg.MaxOutgoingCltvExpiry, MaxChannelFeeAllocation: s.cfg.MaxChannelFeeAllocation, CoopCloseTargetConfs: s.cfg.CoopCloseTargetConfs, + ChannelCloseConfs: s.cfg.Dev.ChannelCloseConfs(), MaxAnchorsCommitFeeRate: chainfee.SatPerKVByte( s.cfg.MaxCommitFeeRateAnchors * 1000).FeePerKWeight(), ChannelCommitInterval: s.cfg.ChannelCommitInterval, From 99b32c9465dc7ddb37ee4969c425c161ca68504e Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Wed, 1 Oct 2025 16:44:44 -0700 Subject: [PATCH 065/134] peer: send out a notification after the 1st conf, then wait for the rest We wnt to add better handling, but not break any UIs or wallets. So we'll continue to send out a notification after a single confirmation, then send another after things are fully confirmed. --- peer/brontide.go | 43 ++++++++++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/peer/brontide.go b/peer/brontide.go index 7d69aeefb..61e638f6b 100644 --- a/peer/brontide.go +++ b/peer/brontide.go @@ -4450,19 +4450,27 @@ func (p *Brontide) finalizeChanClosure(chanCloser *chancloser.ChanCloser) { localOut := chanCloser.LocalCloseOutput() remoteOut := chanCloser.RemoteCloseOutput() auxOut := chanCloser.AuxOutputs() - // Determine the number of confirmations to wait before - // signaling a successful cooperative close, scaled by - // channel capacity (see CloseConfsForCapacity). - numConfs := lnwallet.CloseConfsForCapacity(chanCloser.Channel().Capacity) - go WaitForChanToClose( - chanCloser.NegotiationHeight(), notifier, errChan, - &chanPoint, &closingTxid, closingTx.TxOut[0].PkScript, numConfs, func() { - // Respond to the local subsystem which requested the - // channel closure. - if closeReq != nil { - closeReq.Updates <- &ChannelCloseUpdate{ - ClosingTxid: closingTxid[:], + // Determine the number of confirmations to wait before signaling a + // successful cooperative close, scaled by channel capacity (see + // CloseConfsForCapacity). Check if we have a config override for + // testing purposes. + chanCapacity := chanCloser.Channel().Capacity + numConfs := p.cfg.ChannelCloseConfs.UnwrapOrFunc(func() uint32 { + // No override, use normal capacity-based scaling. + return lnwallet.CloseConfsForCapacity(chanCapacity) + }) + + // Register for full confirmation to send the final update. + closeScript := closingTx.TxOut[0].PkScript + go WaitForChanToClose( + chanCloser.NegotiationHeight(), notifier, errChan, + &chanPoint, &closingTxid, closeScript, numConfs, func() { + // Respond to the local subsystem which requested the + // channel closure. + if closeReq != nil { + closeReq.Updates <- &ChannelCloseUpdate{ + ClosingTxid: closingTxid[:], Success: true, LocalCloseOutput: localOut, RemoteCloseOutput: remoteOut, @@ -4479,15 +4487,16 @@ func (p *Brontide) finalizeChanClosure(chanCloser *chancloser.ChanCloser) { // finally the callback will be executed. If any error is encountered within // the function, then it will be sent over the errChan. func WaitForChanToClose(bestHeight uint32, notifier chainntnfs.ChainNotifier, - errChan chan error, chanPoint *wire.OutPoint, - closingTxID *chainhash.Hash, closeScript []byte, numConfs uint32, cb func()) { + errChan chan error, chanPoint *wire.OutPoint, + closingTxID *chainhash.Hash, closeScript []byte, numConfs uint32, + cb func()) { peerLog.Infof("Waiting for confirmation of close of ChannelPoint(%v) "+ "with txid: %v", chanPoint, closingTxID) - confNtfn, err := notifier.RegisterConfirmationsNtfn( - closingTxID, closeScript, numConfs, bestHeight, - ) + confNtfn, err := notifier.RegisterConfirmationsNtfn( + closingTxID, closeScript, numConfs, bestHeight, + ) if err != nil { if errChan != nil { errChan <- err From 4d5a14de3df6c80e039f0428b3012ff059b5b54c Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Wed, 1 Oct 2025 16:50:19 -0700 Subject: [PATCH 066/134] contractcourt: update close logic to handle re-orgs of depth n-1, where n is num confs In this commit, we update the close logic to handle re-ogs up to the final amount of confirmations. This is done generically, so we're able to handle events such as: coop close confirm, re-org, breach confirm, re-org, force close confirm, re-org, etc. The upcoming set of new tests will exercise all of these cases. We modify the block beat handling to unify the control flow. As it's possible we get the beat, then see the spend, or the oher way around. --- contractcourt/chain_watcher.go | 298 ++++++++++++++++++++++++++++----- 1 file changed, 259 insertions(+), 39 deletions(-) diff --git a/contractcourt/chain_watcher.go b/contractcourt/chain_watcher.go index a0ebbc64e..579339aaf 100644 --- a/contractcourt/chain_watcher.go +++ b/contractcourt/chain_watcher.go @@ -88,6 +88,38 @@ type BreachCloseInfo struct { CloseSummary channeldb.ChannelCloseSummary } +// spendConfirmationState represents the state of spend confirmation tracking +// in the closeObserver state machine. We wait for N confirmations before +// processing any spend to protect against shallow reorgs. +type spendConfirmationState uint8 + +const ( + // spendStateNone indicates no spend has been detected yet. + spendStateNone spendConfirmationState = iota + + // spendStatePending indicates a spend has been detected and we're + // waiting for the required number of confirmations. + spendStatePending + + // spendStateConfirmed indicates the spend has reached the required + // confirmations and has been processed. + spendStateConfirmed +) + +// String returns a human-readable representation of the state. +func (s spendConfirmationState) String() string { + switch s { + case spendStateNone: + return "None" + case spendStatePending: + return "Pending" + case spendStateConfirmed: + return "Confirmed" + default: + return "Unknown" + } +} + // CommitSet is a collection of the set of known valid commitments at a given // instant. If ConfCommitKey is set, then the commitment identified by the // HtlcSetKey has hit the chain. This struct will be used to examine all live @@ -652,51 +684,226 @@ func newChainSet(chanState *channeldb.OpenChannel) (*chainSet, error) { } // closeObserver is a dedicated goroutine that will watch for any closes of the -// channel that it's watching on chain. In the event of an on-chain event, the -// close observer will assembled the proper materials required to claim the -// funds of the channel on-chain (if required), then dispatch these as -// notifications to all subscribers. +// channel that it's watching on chain. It implements a state machine to handle +// spend detection and confirmation with reorg protection. The states are: +// +// - None (confNtfn == nil): No spend detected yet, waiting for spend +// notification +// +// - Pending (confNtfn != nil): Spend detected, waiting for N confirmations +// +// - Confirmed: Spend confirmed with N blocks, close has been processed func (c *chainWatcher) closeObserver() { defer c.wg.Done() - defer c.fundingSpendNtfn.Cancel() + + registerForSpend := func() (*chainntnfs.SpendEvent, error) { + fundingPkScript, err := deriveFundingPkScript(c.cfg.chanState) + if err != nil { + return nil, err + } + + heightHint := c.cfg.chanState.DeriveHeightHint() + + return c.cfg.notifier.RegisterSpendNtfn( + &c.cfg.chanState.FundingOutpoint, + fundingPkScript, + heightHint, + ) + } + + spendNtfn := c.fundingSpendNtfn + defer spendNtfn.Cancel() + + // We use these variables to implement a state machine to track the + // state of the spend confirmation process: + // * When confNtfn is nil, we're in state "None" waiting for a spend. + // * When confNtfn is set, we're in state "Pending" waiting for + // confirmations. + // + // After confirmations, we transition to state "Confirmed" and clean up. + var ( + pendingSpend *chainntnfs.SpendDetail + confNtfn *chainntnfs.ConfirmationEvent + ) log.Infof("Close observer for ChannelPoint(%v) active", c.cfg.chanState.FundingOutpoint) + // handleSpendDetection processes a newly detected spend by registering + // for confirmations. Returns the new confNtfn or error. + handleSpendDetection := func( + spend *chainntnfs.SpendDetail, + ) (*chainntnfs.ConfirmationEvent, error) { + + // If we already have a pending spend, check if it's the same + // transaction. This can happen if both the spend notification + // and blockbeat detect the same spend. + if pendingSpend != nil { + if *pendingSpend.SpenderTxHash == *spend.SpenderTxHash { + log.Debugf("ChannelPoint(%v): ignoring "+ + "duplicate spend detection for tx %v", + c.cfg.chanState.FundingOutpoint, + spend.SpenderTxHash) + return confNtfn, nil + } + + // Different spend detected. Cancel existing confNtfn + // and replace with new one. + log.Warnf("ChannelPoint(%v): detected different "+ + "spend tx %v, replacing pending tx %v", + c.cfg.chanState.FundingOutpoint, + spend.SpenderTxHash, + pendingSpend.SpenderTxHash) + + if confNtfn != nil { + confNtfn.Cancel() + } + } + + numConfs := c.requiredConfsForSpend() + txid := spend.SpenderTxHash + + newConfNtfn, err := c.cfg.notifier.RegisterConfirmationsNtfn( + txid, spend.SpendingTx.TxOut[0].PkScript, + numConfs, uint32(spend.SpendingHeight), + ) + if err != nil { + return nil, fmt.Errorf("register confirmations: %w", + err) + } + + log.Infof("ChannelPoint(%v): waiting for %d confirmations "+ + "of spend tx %v", c.cfg.chanState.FundingOutpoint, + numConfs, txid) + + return newConfNtfn, nil + } + for { + // We only listen to confirmation channels when we have a + // pending spend. By setting these to nil when not needed, Go's + // select ignores those cases, effectively implementing our + // state machine. + var ( + confChan <-chan *chainntnfs.TxConfirmation + negativeConfChan <-chan int32 + ) + if confNtfn != nil { + confChan = confNtfn.Confirmed + negativeConfChan = confNtfn.NegativeConf + } + select { - // A new block is received, we will check whether this block - // contains a spending tx that we are interested in. case beat := <-c.BlockbeatChan: log.Debugf("ChainWatcher(%v) received blockbeat %v", c.cfg.chanState.FundingOutpoint, beat.Height()) - // Process the block. - c.handleBlockbeat(beat) + spend := c.handleBlockbeat(beat) + if spend == nil { + continue + } - // If the funding outpoint is spent, we now go ahead and handle - // it. Note that we cannot rely solely on the `block` event - // above to trigger a close event, as deep down, the receiving - // of block notifications and the receiving of spending - // notifications are done in two different goroutines, so the - // expected order: [receive block -> receive spend] is not - // guaranteed . - case spend, ok := <-c.fundingSpendNtfn.Spend: - // If the channel was closed, then this means that the - // notifier exited, so we will as well. + // STATE TRANSITION: None -> Pending (from blockbeat). + log.Infof("ChannelPoint(%v): detected spend from "+ + "blockbeat, transitioning to %v", + c.cfg.chanState.FundingOutpoint, + spendStatePending) + + newConfNtfn, err := handleSpendDetection(spend) + if err != nil { + log.Errorf("Unable to handle spend "+ + "detection: %v", err) + return + } + pendingSpend = spend + confNtfn = newConfNtfn + + // STATE TRANSITION: None -> Pending. + // We've detected a spend, but don't process it yet. Instead, + // register for confirmations to protect against shallow reorgs. + case spend, ok := <-spendNtfn.Spend: if !ok { return } - err := c.handleCommitSpend(spend) + log.Infof("ChannelPoint(%v): detected spend from "+ + "notification, transitioning to %v", + c.cfg.chanState.FundingOutpoint, + spendStatePending) + + newConfNtfn, err := handleSpendDetection(spend) if err != nil { - log.Errorf("Failed to handle commit spend: %v", - err) + log.Errorf("Unable to handle spend "+ + "detection: %v", err) + return } + pendingSpend = spend + confNtfn = newConfNtfn + + // STATE TRANSITION: Pending -> Confirmed + // The spend has reached required confirmations. It's now safe + // to process since we've protected against shallow reorgs. + case conf, ok := <-confChan: + if !ok { + log.Errorf("Confirmation channel closed " + + "unexpectedly") + return + } + + log.Infof("ChannelPoint(%v): spend confirmed at "+ + "height %d, transitioning to %v", + c.cfg.chanState.FundingOutpoint, + conf.BlockHeight, spendStateConfirmed) + + err := c.handleCommitSpend(pendingSpend) + if err != nil { + log.Errorf("Failed to handle confirmed "+ + "spend: %v", err) + } + + confNtfn.Cancel() + confNtfn = nil + pendingSpend = nil + + // STATE TRANSITION: Pending -> None + // A reorg removed the spend tx. We reset to initial state and + // wait for ANY new spend (could be the same tx re-mined, or a + // different tx like an RBF replacement). + case reorgDepth, ok := <-negativeConfChan: + if !ok { + log.Errorf("Negative conf channel closed " + + "unexpectedly") + return + } + + log.Infof("ChannelPoint(%v): spend reorged out at "+ + "depth %d, transitioning back to %v", + c.cfg.chanState.FundingOutpoint, reorgDepth, + spendStateNone) + + confNtfn.Cancel() + confNtfn = nil + pendingSpend = nil + + spendNtfn.Cancel() + var err error + spendNtfn, err = registerForSpend() + if err != nil { + log.Errorf("Unable to re-register for "+ + "spend: %v", err) + return + } + + log.Infof("ChannelPoint(%v): re-registered for spend "+ + "detection", c.cfg.chanState.FundingOutpoint) // The chainWatcher has been signalled to exit, so we'll do so // now. case <-c.quit: + if confNtfn != nil { + confNtfn.Cancel() + } + return } } @@ -992,6 +1199,18 @@ func (c *chainWatcher) toSelfAmount(tx *wire.MsgTx) btcutil.Amount { return btcutil.Amount(fn.Sum(vals)) } +// requiredConfsForSpend determines the number of confirmations required before +// processing a spend of the funding output. Uses config override if set +// (typically for testing), otherwise scales with channel capacity to balance +// security vs user experience for channels of different sizes. +func (c *chainWatcher) requiredConfsForSpend() uint32 { + return c.cfg.chanCloseConfs.UnwrapOrFunc(func() uint32 { + return lnwallet.CloseConfsForCapacity( + c.cfg.chanState.Capacity, + ) + }) +} + // dispatchCooperativeClose processed a detect cooperative channel closure. // We'll use the spending transaction to locate our output within the // transaction, then clean up the database state. We'll also dispatch a @@ -1009,8 +1228,8 @@ func (c *chainWatcher) dispatchCooperativeClose(commitSpend *chainntnfs.SpendDet localAmt := c.toSelfAmount(broadcastTx) // Once this is known, we'll mark the state as fully closed in the - // database. We can do this as a cooperatively closed channel has all - // its outputs resolved after only one confirmation. + // database. For cooperative closes, we wait for a confirmation depth + // determined by channel capacity before dispatching this event. closeSummary := &channeldb.ChannelCloseSummary{ ChanPoint: c.cfg.chanState.FundingOutpoint, ChainHash: c.cfg.chanState.ChainHash, @@ -1420,9 +1639,10 @@ func (c *chainWatcher) handleCommitSpend( case wire.MaxTxInSequenceNum: fallthrough case mempool.MaxRBFSequence: - // TODO(roasbeef): rare but possible, need itest case for - err := c.dispatchCooperativeClose(commitSpend) - if err != nil { + // This is a cooperative close. Dispatch it directly - the + // confirmation waiting and reorg handling is done in the + // closeObserver state machine before we reach this point. + if err := c.dispatchCooperativeClose(commitSpend); err != nil { return fmt.Errorf("handle coop close: %w", err) } @@ -1527,9 +1747,10 @@ func (c *chainWatcher) chanPointConfirmed() bool { } // handleBlockbeat takes a blockbeat and queries for a spending tx for the -// funding output. If the spending tx is found, it will be handled based on the -// closure type. -func (c *chainWatcher) handleBlockbeat(beat chainio.Blockbeat) { +// funding output. If found, it returns the spend details so closeObserver can +// process it. Returns nil if no spend was detected. +func (c *chainWatcher) handleBlockbeat( + beat chainio.Blockbeat) *chainntnfs.SpendDetail { // Notify the chain watcher has processed the block. defer c.NotifyBlockProcessed(beat, nil) @@ -1541,24 +1762,23 @@ func (c *chainWatcher) handleBlockbeat(beat chainio.Blockbeat) { // If the funding output hasn't confirmed in this block, we // will check it again in the next block. if !c.chanPointConfirmed() { - return + return nil } } // Perform a non-blocking read to check whether the funding output was - // spent. + // spent. The actual spend handling is done in closeObserver's state + // machine to avoid blocking the block processing pipeline. spend := c.checkFundingSpend() if spend == nil { log.Tracef("No spend found for ChannelPoint(%v) in block %v", c.cfg.chanState.FundingOutpoint, beat.Height()) - return + return nil } - // The funding output was spent, we now handle it by sending a close - // event to the channel arbitrator. - err := c.handleCommitSpend(spend) - if err != nil { - log.Errorf("Failed to handle commit spend: %v", err) - } + log.Debugf("Detected spend of ChannelPoint(%v) in block %v", + c.cfg.chanState.FundingOutpoint, beat.Height()) + + return spend } From a8e37b08e4df47db12c66f6154c87ab7d0b8e41b Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Wed, 1 Oct 2025 16:50:39 -0700 Subject: [PATCH 067/134] lntest: add new wait for conf helper method to ChainNotifier --- lntest/harness_assertion.go | 7 +++++-- lntest/mock/chainnotifier.go | 40 +++++++++++++++++++++++++++++++++--- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/lntest/harness_assertion.go b/lntest/harness_assertion.go index 544576bef..011c03d8d 100644 --- a/lntest/harness_assertion.go +++ b/lntest/harness_assertion.go @@ -18,6 +18,7 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" + "github.com/davecgh/go-spew/spew" "github.com/lightningnetwork/lnd/channeldb" "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnrpc/invoicesrpc" @@ -552,8 +553,10 @@ func (h HarnessTest) WaitForChannelCloseEvent( require.NoError(h, err) resp, ok := event.Update.(*lnrpc.CloseStatusUpdate_ChanClose) - require.Truef(h, ok, "expected channel close update, instead got %v", - event.Update) + require.Truef( + h, ok, "expected channel close update, instead got %T: %v", + event.Update, spew.Sdump(event.Update), + ) txid, err := chainhash.NewHash(resp.ChanClose.ClosingTxid) require.NoErrorf(h, err, "wrong format found in closing txid: %v", diff --git a/lntest/mock/chainnotifier.go b/lntest/mock/chainnotifier.go index ddce8defa..9a9e125bd 100644 --- a/lntest/mock/chainnotifier.go +++ b/lntest/mock/chainnotifier.go @@ -1,6 +1,9 @@ package mock import ( + "testing" + "time" + "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/lightningnetwork/lnd/chainntnfs" @@ -8,9 +11,10 @@ import ( // ChainNotifier is a mock implementation of the ChainNotifier interface. type ChainNotifier struct { - SpendChan chan *chainntnfs.SpendDetail - EpochChan chan *chainntnfs.BlockEpoch - ConfChan chan *chainntnfs.TxConfirmation + SpendChan chan *chainntnfs.SpendDetail + EpochChan chan *chainntnfs.BlockEpoch + ConfChan chan *chainntnfs.TxConfirmation + ConfRegistered chan struct{} } // RegisterConfirmationsNtfn returns a ConfirmationEvent that contains a channel @@ -19,6 +23,14 @@ func (c *ChainNotifier) RegisterConfirmationsNtfn(txid *chainhash.Hash, pkScript []byte, numConfs, heightHint uint32, opts ...chainntnfs.NotifierOption) (*chainntnfs.ConfirmationEvent, error) { + // Signal that a confirmation registration occurred. + if c.ConfRegistered != nil { + select { + case c.ConfRegistered <- struct{}{}: + default: + } + } + return &chainntnfs.ConfirmationEvent{ Confirmed: c.ConfChan, Cancel: func() {}, @@ -61,3 +73,25 @@ func (c *ChainNotifier) Started() bool { func (c *ChainNotifier) Stop() error { return nil } + +// WaitForConfRegistrationAndSend waits for a confirmation registration to +// occur and then sends a confirmation notification. This is a helper function +// for tests that need to ensure the chain watcher has registered for +// confirmations before sending the confirmation. +func (c *ChainNotifier) WaitForConfRegistrationAndSend(t *testing.T) { + t.Helper() + + // Wait for the chain watcher to register for confirmations. + select { + case <-c.ConfRegistered: + case <-time.After(time.Second * 2): + t.Fatalf("timeout waiting for conf registration") + } + + // Send the confirmation to satisfy the confirmation requirement. + select { + case c.ConfChan <- &chainntnfs.TxConfirmation{}: + case <-time.After(time.Second * 1): + t.Fatalf("unable to send confirmation") + } +} From 6f6034dd5163538fdc1e25b77b2c603fcec94385 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Wed, 1 Oct 2025 16:51:00 -0700 Subject: [PATCH 068/134] contractcourt: add new chainWatcherTestHarness We'll use this for all the upcoming tests. --- contractcourt/chain_watcher_test_harness.go | 656 ++++++++++++++++++++ 1 file changed, 656 insertions(+) create mode 100644 contractcourt/chain_watcher_test_harness.go diff --git a/contractcourt/chain_watcher_test_harness.go b/contractcourt/chain_watcher_test_harness.go new file mode 100644 index 000000000..09ab03592 --- /dev/null +++ b/contractcourt/chain_watcher_test_harness.go @@ -0,0 +1,656 @@ +package contractcourt + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/channeldb" + "github.com/lightningnetwork/lnd/fn/v2" + lnmock "github.com/lightningnetwork/lnd/lntest/mock" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/lnwire" +) + +// testReporter is a minimal interface for test reporting that is satisfied +// by both *testing.T and *rapid.T, allowing the harness to work with +// property-based tests. +type testReporter interface { + Helper() + Fatalf(format string, args ...any) +} + +// chainWatcherTestHarness provides a test harness for chain watcher tests +// with utilities for simulating spends, confirmations, and reorganizations. +type chainWatcherTestHarness struct { + t testReporter + + // aliceChannel and bobChannel are the test channels. + aliceChannel *lnwallet.LightningChannel + bobChannel *lnwallet.LightningChannel + + // chainWatcher is the chain watcher under test. + chainWatcher *chainWatcher + + // notifier is the mock chain notifier. + notifier *mockChainNotifier + + // chanEvents is the channel event subscription. + chanEvents *ChainEventSubscription + + // currentHeight tracks the current block height. + currentHeight int32 + + // blockbeatProcessed is a channel that signals when a blockbeat has + // been processed. + blockbeatProcessed chan struct{} +} + +// mockChainNotifier extends the standard mock with additional channels for +// testing cooperative close reorgs. +type mockChainNotifier struct { + *lnmock.ChainNotifier + + // confEvents tracks active confirmation event subscriptions. + confEvents []*mockConfirmationEvent + + // confRegistered is a channel that signals when a new confirmation + // event has been registered. + confRegistered chan struct{} + + // spendEvents tracks active spend event subscriptions. + spendEvents []*chainntnfs.SpendEvent + + // spendRegistered is a channel that signals when a new spend + // event has been registered. + spendRegistered chan struct{} +} + +// mockConfirmationEvent represents a mock confirmation event subscription. +type mockConfirmationEvent struct { + txid chainhash.Hash + numConfs uint32 + confirmedChan chan *chainntnfs.TxConfirmation + negConfChan chan int32 + cancelled bool +} + +// RegisterSpendNtfn creates a new mock spend event. +func (m *mockChainNotifier) RegisterSpendNtfn(outpoint *wire.OutPoint, + pkScript []byte, heightHint uint32) (*chainntnfs.SpendEvent, error) { + + // The base mock already has SpendChan, use that. + spendEvent := &chainntnfs.SpendEvent{ + Spend: m.SpendChan, + Cancel: func() { + // No-op for now. + }, + } + + m.spendEvents = append(m.spendEvents, spendEvent) + + // Signal that a new spend event has been registered. + select { + case m.spendRegistered <- struct{}{}: + default: + } + + return spendEvent, nil +} + +// RegisterConfirmationsNtfn creates a new mock confirmation event. +func (m *mockChainNotifier) RegisterConfirmationsNtfn(txid *chainhash.Hash, + pkScript []byte, numConfs, heightHint uint32, + opts ...chainntnfs.NotifierOption, +) (*chainntnfs.ConfirmationEvent, error) { + + mockEvent := &mockConfirmationEvent{ + txid: *txid, + numConfs: numConfs, + confirmedChan: make(chan *chainntnfs.TxConfirmation, 1), + negConfChan: make(chan int32, 1), + } + + m.confEvents = append(m.confEvents, mockEvent) + + // Signal that a new confirmation event has been registered. + select { + case m.confRegistered <- struct{}{}: + default: + } + + return &chainntnfs.ConfirmationEvent{ + Confirmed: mockEvent.confirmedChan, + NegativeConf: mockEvent.negConfChan, + Cancel: func() { + mockEvent.cancelled = true + }, + }, nil +} + +// harnessOpt is a functional option for configuring the test harness. +type harnessOpt func(*harnessConfig) + +// harnessConfig holds configuration for the test harness. +type harnessConfig struct { + requiredConfs fn.Option[uint32] +} + +// withRequiredConfs sets the number of confirmations required for channel +// closes. +func withRequiredConfs(confs uint32) harnessOpt { + return func(cfg *harnessConfig) { + cfg.requiredConfs = fn.Some(confs) + } +} + +// newChainWatcherTestHarness creates a new test harness for chain watcher +// tests. +func newChainWatcherTestHarness(t *testing.T, + opts ...harnessOpt) *chainWatcherTestHarness { + + return newChainWatcherTestHarnessFromReporter(t, t, opts...) +} + +// newChainWatcherTestHarnessFromReporter creates a test harness that works +// with both *testing.T and *rapid.T. The t parameter is used for +// operations that specifically require *testing.T (like CreateTestChannels), +// while reporter is used for all test reporting (Helper, Fatalf). +func newChainWatcherTestHarnessFromReporter(t *testing.T, + reporter testReporter, opts ...harnessOpt) *chainWatcherTestHarness { + + reporter.Helper() + + // Apply options. + cfg := &harnessConfig{ + requiredConfs: fn.None[uint32](), + } + for _, opt := range opts { + opt(cfg) + } + + // Create test channels. + aliceChannel, bobChannel, err := lnwallet.CreateTestChannels( + t, channeldb.SingleFunderTweaklessBit, + ) + if err != nil { + reporter.Fatalf("unable to create test channels: %v", err) + } + + // Create mock notifier. + baseNotifier := &lnmock.ChainNotifier{ + SpendChan: make(chan *chainntnfs.SpendDetail, 1), + EpochChan: make(chan *chainntnfs.BlockEpoch), + ConfChan: make(chan *chainntnfs.TxConfirmation, 1), + } + + notifier := &mockChainNotifier{ + ChainNotifier: baseNotifier, + confEvents: make([]*mockConfirmationEvent, 0), + confRegistered: make(chan struct{}, 10), + spendEvents: make([]*chainntnfs.SpendEvent, 0), + spendRegistered: make(chan struct{}, 10), + } + + // Create chain watcher. + chainWatcher, err := newChainWatcher(chainWatcherConfig{ + chanState: aliceChannel.State(), + notifier: notifier, + signer: aliceChannel.Signer, + extractStateNumHint: lnwallet.GetStateNumHint, + chanCloseConfs: cfg.requiredConfs, + contractBreach: func( + retInfo *lnwallet.BreachRetribution, + ) error { + // In tests, we just need to accept the breach + // notification. + return nil + }, + }) + if err != nil { + reporter.Fatalf("unable to create chain watcher: %v", err) + } + + // Start chain watcher (this will register for spend notification). + err = chainWatcher.Start() + if err != nil { + reporter.Fatalf("unable to start chain watcher: %v", err) + } + + // Subscribe to channel events. + chanEvents := chainWatcher.SubscribeChannelEvents() + + harness := &chainWatcherTestHarness{ + t: reporter, + aliceChannel: aliceChannel, + bobChannel: bobChannel, + chainWatcher: chainWatcher, + notifier: notifier, + chanEvents: chanEvents, + currentHeight: 100, + blockbeatProcessed: make(chan struct{}), + } + + // Wait for the initial spend registration that happens in Start(). + harness.waitForSpendRegistration() + + // Verify BlockbeatChan is initialized. + if chainWatcher.BlockbeatChan == nil { + reporter.Fatalf("BlockbeatChan is nil after initialization") + } + + // Register cleanup. We use the t for Cleanup since rapid.T + // may not have this method in the same way. + t.Cleanup(func() { + _ = chainWatcher.Stop() + }) + + return harness +} + +// createCoopCloseTx creates a cooperative close transaction with the given +// output value. The transaction will have the proper sequence number to +// indicate it's a cooperative close. +func (h *chainWatcherTestHarness) createCoopCloseTx( + outputValue int64) *wire.MsgTx { + + fundingOutpoint := h.aliceChannel.State().FundingOutpoint + + return &wire.MsgTx{ + TxIn: []*wire.TxIn{{ + PreviousOutPoint: fundingOutpoint, + Sequence: wire.MaxTxInSequenceNum, + }}, + TxOut: []*wire.TxOut{{ + Value: outputValue, + // Unique script. + PkScript: []byte{byte(outputValue % 255)}, + }}, + } +} + +// createRemoteForceCloseTx creates a remote force close transaction. +// From Alice's perspective, this is Bob's local commitment transaction. +func (h *chainWatcherTestHarness) createRemoteForceCloseTx() *wire.MsgTx { + return h.bobChannel.State().LocalCommitment.CommitTx +} + +// createLocalForceCloseTx creates a local force close transaction. +// This is Alice's local commitment transaction. +func (h *chainWatcherTestHarness) createLocalForceCloseTx() *wire.MsgTx { + return h.aliceChannel.State().LocalCommitment.CommitTx +} + +// createBreachCloseTx creates a breach (revoked commitment) transaction. +// We advance the channel state, save the commitment, then advance again +// to revoke it. Returns the revoked commitment tx. +func (h *chainWatcherTestHarness) createBreachCloseTx() *wire.MsgTx { + h.t.Helper() + + // To create a revoked commitment, we need to advance the channel state + // at least once. We'll use the test utils helper to add an HTLC and + // force a state transition. + + // Get the current commitment before we advance (this will be revoked). + revokedCommit := h.bobChannel.State().LocalCommitment.CommitTx + + // Add a fake HTLC to advance state. + htlcAmount := lnwire.NewMSatFromSatoshis(10000) + paymentHash := [32]byte{4, 5, 6} + htlc := &lnwire.UpdateAddHTLC{ + ID: 0, + Amount: htlcAmount, + Expiry: uint32(h.currentHeight + 100), + PaymentHash: paymentHash, + } + + // Add HTLC to both channels. + if _, err := h.aliceChannel.AddHTLC(htlc, nil); err != nil { + h.t.Fatalf("unable to add HTLC to alice: %v", err) + } + if _, err := h.bobChannel.ReceiveHTLC(htlc); err != nil { + h.t.Fatalf("unable to add HTLC to bob: %v", err) + } + + // Force state transition using the helper. + err := lnwallet.ForceStateTransition(h.aliceChannel, h.bobChannel) + if err != nil { + h.t.Fatalf("unable to force state transition: %v", err) + } + + // Return the revoked commitment (Bob's previous local commitment). + return revokedCommit +} + +// sendSpend sends a spend notification for the given transaction. +func (h *chainWatcherTestHarness) sendSpend(tx *wire.MsgTx) { + h.t.Helper() + + txHash := tx.TxHash() + spend := &chainntnfs.SpendDetail{ + SpenderTxHash: &txHash, + SpendingTx: tx, + SpendingHeight: h.currentHeight, + } + + select { + case h.notifier.SpendChan <- spend: + case <-time.After(time.Second): + h.t.Fatalf("unable to send spend notification") + } +} + +// confirmTx sends a confirmation notification for the given transaction. +func (h *chainWatcherTestHarness) confirmTx(tx *wire.MsgTx, height int32) { + h.t.Helper() + + // Find the confirmation event for this transaction. + txHash := tx.TxHash() + var confEvent *mockConfirmationEvent + for _, event := range h.notifier.confEvents { + if event.txid == txHash && !event.cancelled { + confEvent = event + break + } + } + + if confEvent == nil { + h.t.Fatalf("no confirmation event registered for tx %v", txHash) + } + + // Send confirmation. + select { + case confEvent.confirmedChan <- &chainntnfs.TxConfirmation{ + Tx: tx, + BlockHeight: uint32(height), + }: + case <-time.After(time.Second): + h.t.Fatalf("unable to send confirmation") + } +} + +// triggerReorg sends a negative confirmation (reorg) notification for the +// given transaction with the specified reorg depth. +func (h *chainWatcherTestHarness) triggerReorg(tx *wire.MsgTx, + reorgDepth int32) { + + h.t.Helper() + + // Find the confirmation event for this transaction. + txHash := tx.TxHash() + var confEvent *mockConfirmationEvent + for _, event := range h.notifier.confEvents { + if event.txid == txHash && !event.cancelled { + confEvent = event + break + } + } + + if confEvent == nil { + // The chain watcher might not have registered for + // confirmations yet. + return + } + + // Send negative confirmation. + select { + case confEvent.negConfChan <- reorgDepth: + case <-time.After(time.Second): + h.t.Fatalf("unable to send negative confirmation") + } +} + +// mineBlocks advances the current block height. +func (h *chainWatcherTestHarness) mineBlocks(n int32) { + h.currentHeight += n +} + +// waitForCoopClose waits for a cooperative close event and returns it. +func (h *chainWatcherTestHarness) waitForCoopClose( + timeout time.Duration) *CooperativeCloseInfo { + + h.t.Helper() + + select { + case coopClose := <-h.chanEvents.CooperativeClosure: + return coopClose + case <-time.After(timeout): + h.t.Fatalf("didn't receive cooperative close event") + return nil + } +} + +// waitForConfRegistration waits for the chain watcher to register for +// confirmation notifications. +func (h *chainWatcherTestHarness) waitForConfRegistration() { + h.t.Helper() + + select { + case <-h.notifier.confRegistered: + // Registration complete. + case <-time.After(2 * time.Second): + // Not necessarily a failure - some tests don't register. + } +} + +// waitForSpendRegistration waits for the chain watcher to register for +// spend notifications. +func (h *chainWatcherTestHarness) waitForSpendRegistration() { + h.t.Helper() + + select { + case <-h.notifier.spendRegistered: + // Registration complete. + case <-time.After(2 * time.Second): + // Not necessarily a failure - some tests don't register. + } +} + +// assertCoopCloseTx asserts that the given cooperative close info matches +// the expected transaction. +func (h *chainWatcherTestHarness) assertCoopCloseTx( + closeInfo *CooperativeCloseInfo, expectedTx *wire.MsgTx) { + + h.t.Helper() + + expectedHash := expectedTx.TxHash() + if closeInfo.ClosingTXID != expectedHash { + h.t.Fatalf("wrong tx confirmed: expected %v, got %v", + expectedHash, closeInfo.ClosingTXID) + } +} + +// assertNoCoopClose asserts that no cooperative close event is received +// within the given timeout. +func (h *chainWatcherTestHarness) assertNoCoopClose(timeout time.Duration) { + h.t.Helper() + + select { + case <-h.chanEvents.CooperativeClosure: + h.t.Fatalf("unexpected cooperative close event") + case <-time.After(timeout): + // Expected timeout. + } +} + +// runCoopCloseFlow runs a complete cooperative close flow including spend, +// optional reorg, and confirmation. This helper coordinates the timing +// between the different events. +func (h *chainWatcherTestHarness) runCoopCloseFlow( + tx *wire.MsgTx, shouldReorg bool, reorgDepth int32, + altTx *wire.MsgTx) *CooperativeCloseInfo { + + h.t.Helper() + + // Send initial spend notification. The closeObserver's state machine + // will detect this and register for confirmations. + h.sendSpend(tx) + + // Wait for the chain watcher to register for confirmations. + h.waitForConfRegistration() + + if shouldReorg { + // Trigger reorg which resets the state machine. + h.triggerReorg(tx, reorgDepth) + + // If we have an alternative transaction, send it. + if altTx != nil { + // After reorg, the chain watcher should re-register for + // ANY spend of the funding output. + h.waitForSpendRegistration() + + // Send alternative spend. + h.sendSpend(altTx) + + // Wait for it to register for confirmations. + h.waitForConfRegistration() + + // Confirm alternative transaction to unblock. + h.mineBlocks(1) + h.confirmTx(altTx, h.currentHeight) + } + } else { + // Normal confirmation flow - confirm to unblock + // waitForCoopCloseConfirmation. + h.mineBlocks(1) + h.confirmTx(tx, h.currentHeight) + } + + // Wait for cooperative close event. + return h.waitForCoopClose(5 * time.Second) +} + +// runMultipleReorgFlow simulates multiple consecutive reorganizations with +// different transactions confirming after each reorg. +func (h *chainWatcherTestHarness) runMultipleReorgFlow(txs []*wire.MsgTx, + reorgDepths []int32) *CooperativeCloseInfo { + + h.t.Helper() + + if len(txs) < 2 { + h.t.Fatalf("need at least 2 transactions for reorg flow") + } + if len(reorgDepths) != len(txs)-1 { + h.t.Fatalf("reorg depths must be one less than transactions") + } + + // Send initial spend. + h.sendSpend(txs[0]) + + // Process each reorg. + for i, depth := range reorgDepths { + // Wait for confirmation registration. + h.waitForConfRegistration() + + // Trigger reorg for current transaction. + h.triggerReorg(txs[i], depth) + + // Wait for re-registration for spend. + h.waitForSpendRegistration() + + // Send next transaction. + h.sendSpend(txs[i+1]) + } + + // Wait for final confirmation registration. + h.waitForConfRegistration() + + // Confirm the final transaction. + finalTx := txs[len(txs)-1] + h.mineBlocks(1) + h.confirmTx(finalTx, h.currentHeight) + + // Wait for cooperative close event. + return h.waitForCoopClose(10 * time.Second) +} + +// waitForRemoteUnilateralClose waits for a remote unilateral close event. +func (h *chainWatcherTestHarness) waitForRemoteUnilateralClose( + timeout time.Duration) *RemoteUnilateralCloseInfo { + + h.t.Helper() + + select { + case remoteClose := <-h.chanEvents.RemoteUnilateralClosure: + return remoteClose + case <-time.After(timeout): + h.t.Fatalf("didn't receive remote unilateral close event") + return nil + } +} + +// waitForLocalUnilateralClose waits for a local unilateral close event. +func (h *chainWatcherTestHarness) waitForLocalUnilateralClose( + timeout time.Duration) *LocalUnilateralCloseInfo { + + h.t.Helper() + + select { + case localClose := <-h.chanEvents.LocalUnilateralClosure: + return localClose + case <-time.After(timeout): + h.t.Fatalf("didn't receive local unilateral close event") + return nil + } +} + +// waitForBreach waits for a breach (contract breach) event. +func (h *chainWatcherTestHarness) waitForBreach( + timeout time.Duration) *BreachCloseInfo { + + h.t.Helper() + + select { + case breach := <-h.chanEvents.ContractBreach: + return breach + case <-time.After(timeout): + h.t.Fatalf("didn't receive contract breach event") + return nil + } +} + +// assertRemoteUnilateralCloseTx asserts that the given remote unilateral close +// info matches the expected transaction. +func (h *chainWatcherTestHarness) assertRemoteUnilateralCloseTx( + closeInfo *RemoteUnilateralCloseInfo, expectedTx *wire.MsgTx) { + + h.t.Helper() + + expectedHash := expectedTx.TxHash() + actualHash := closeInfo.UnilateralCloseSummary.SpendDetail.SpenderTxHash + if *actualHash != expectedHash { + h.t.Fatalf("wrong tx confirmed: expected %v, got %v", + expectedHash, *actualHash) + } +} + +// assertLocalUnilateralCloseTx asserts that the given local unilateral close +// info matches the expected transaction. +func (h *chainWatcherTestHarness) assertLocalUnilateralCloseTx( + closeInfo *LocalUnilateralCloseInfo, expectedTx *wire.MsgTx) { + + h.t.Helper() + + expectedHash := expectedTx.TxHash() + actualHash := closeInfo.LocalForceCloseSummary.CloseTx.TxHash() + if actualHash != expectedHash { + h.t.Fatalf("wrong tx confirmed: expected %v, got %v", + expectedHash, actualHash) + } +} + +// assertBreachTx asserts that the given breach info matches the expected +// transaction. +func (h *chainWatcherTestHarness) assertBreachTx( + breachInfo *BreachCloseInfo, expectedTx *wire.MsgTx) { + + h.t.Helper() + + expectedHash := expectedTx.TxHash() + if breachInfo.CommitHash != expectedHash { + h.t.Fatalf("wrong tx confirmed: expected %v, got %v", + expectedHash, breachInfo.CommitHash) + } +} From 19d8bb2a6c24b731d3c43f03cee18a059ce9022f Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Wed, 1 Oct 2025 16:51:30 -0700 Subject: [PATCH 069/134] contractcourt: update existing chain watcher tests due to new logic All the tests need to send a confirmation _after_ the spend is detected now. --- contractcourt/chain_watcher_test.go | 49 ++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/contractcourt/chain_watcher_test.go b/contractcourt/chain_watcher_test.go index 2dc3605d3..c57859ca4 100644 --- a/contractcourt/chain_watcher_test.go +++ b/contractcourt/chain_watcher_test.go @@ -12,6 +12,7 @@ import ( "github.com/lightningnetwork/lnd/chainio" "github.com/lightningnetwork/lnd/chainntnfs" "github.com/lightningnetwork/lnd/channeldb" + "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/input" lnmock "github.com/lightningnetwork/lnd/lntest/mock" "github.com/lightningnetwork/lnd/lnwallet" @@ -34,16 +35,19 @@ func TestChainWatcherRemoteUnilateralClose(t *testing.T) { // With the channels created, we'll now create a chain watcher instance // which will be watching for any closes of Alice's channel. + confRegistered := make(chan struct{}, 1) aliceNotifier := &lnmock.ChainNotifier{ - SpendChan: make(chan *chainntnfs.SpendDetail, 1), - EpochChan: make(chan *chainntnfs.BlockEpoch), - ConfChan: make(chan *chainntnfs.TxConfirmation), + SpendChan: make(chan *chainntnfs.SpendDetail, 1), + EpochChan: make(chan *chainntnfs.BlockEpoch), + ConfChan: make(chan *chainntnfs.TxConfirmation, 1), + ConfRegistered: confRegistered, } aliceChainWatcher, err := newChainWatcher(chainWatcherConfig{ chanState: aliceChannel.State(), notifier: aliceNotifier, signer: aliceChannel.Signer, extractStateNumHint: lnwallet.GetStateNumHint, + chanCloseConfs: fn.Some(uint32(1)), }) require.NoError(t, err, "unable to create chain watcher") err = aliceChainWatcher.Start() @@ -90,6 +94,11 @@ func TestChainWatcherRemoteUnilateralClose(t *testing.T) { t.Fatalf("unable to send blockbeat") } + // Wait for the chain watcher to register for confirmations and send + // the confirmation. Since we set chanCloseConfs to 1, one confirmation + // is sufficient. + aliceNotifier.WaitForConfRegistrationAndSend(t) + // We should get a new spend event over the remote unilateral close // event channel. var uniClose *RemoteUnilateralCloseInfo @@ -144,16 +153,19 @@ func TestChainWatcherRemoteUnilateralClosePendingCommit(t *testing.T) { // With the channels created, we'll now create a chain watcher instance // which will be watching for any closes of Alice's channel. + confRegistered := make(chan struct{}, 1) aliceNotifier := &lnmock.ChainNotifier{ - SpendChan: make(chan *chainntnfs.SpendDetail), - EpochChan: make(chan *chainntnfs.BlockEpoch), - ConfChan: make(chan *chainntnfs.TxConfirmation), + SpendChan: make(chan *chainntnfs.SpendDetail), + EpochChan: make(chan *chainntnfs.BlockEpoch), + ConfChan: make(chan *chainntnfs.TxConfirmation), + ConfRegistered: confRegistered, } aliceChainWatcher, err := newChainWatcher(chainWatcherConfig{ chanState: aliceChannel.State(), notifier: aliceNotifier, signer: aliceChannel.Signer, extractStateNumHint: lnwallet.GetStateNumHint, + chanCloseConfs: fn.Some(uint32(1)), }) require.NoError(t, err, "unable to create chain watcher") if err := aliceChainWatcher.Start(); err != nil { @@ -219,6 +231,11 @@ func TestChainWatcherRemoteUnilateralClosePendingCommit(t *testing.T) { t.Fatalf("unable to send blockbeat") } + // Wait for the chain watcher to register for confirmations and send + // the confirmation. Since we set chanCloseConfs to 1, one confirmation + // is sufficient. + aliceNotifier.WaitForConfRegistrationAndSend(t) + // We should get a new spend event over the remote unilateral close // event channel. var uniClose *RemoteUnilateralCloseInfo @@ -331,10 +348,12 @@ func TestChainWatcherDataLossProtect(t *testing.T) { // With the channels created, we'll now create a chain watcher // instance which will be watching for any closes of Alice's // channel. + confRegistered := make(chan struct{}, 1) aliceNotifier := &lnmock.ChainNotifier{ - SpendChan: make(chan *chainntnfs.SpendDetail), - EpochChan: make(chan *chainntnfs.BlockEpoch), - ConfChan: make(chan *chainntnfs.TxConfirmation), + SpendChan: make(chan *chainntnfs.SpendDetail), + EpochChan: make(chan *chainntnfs.BlockEpoch), + ConfChan: make(chan *chainntnfs.TxConfirmation), + ConfRegistered: confRegistered, } aliceChainWatcher, err := newChainWatcher(chainWatcherConfig{ chanState: aliceChanState, @@ -407,6 +426,8 @@ func TestChainWatcherDataLossProtect(t *testing.T) { t.Fatalf("unable to send blockbeat") } + aliceNotifier.WaitForConfRegistrationAndSend(t) + // We should get a new uni close resolution that indicates we // processed the DLP scenario. var uniClose *RemoteUnilateralCloseInfo @@ -532,10 +553,12 @@ func TestChainWatcherLocalForceCloseDetect(t *testing.T) { // With the channels created, we'll now create a chain watcher // instance which will be watching for any closes of Alice's // channel. + confRegistered := make(chan struct{}, 1) aliceNotifier := &lnmock.ChainNotifier{ - SpendChan: make(chan *chainntnfs.SpendDetail), - EpochChan: make(chan *chainntnfs.BlockEpoch), - ConfChan: make(chan *chainntnfs.TxConfirmation), + SpendChan: make(chan *chainntnfs.SpendDetail), + EpochChan: make(chan *chainntnfs.BlockEpoch), + ConfChan: make(chan *chainntnfs.TxConfirmation), + ConfRegistered: confRegistered, } aliceChainWatcher, err := newChainWatcher(chainWatcherConfig{ chanState: aliceChanState, @@ -604,6 +627,8 @@ func TestChainWatcherLocalForceCloseDetect(t *testing.T) { t.Fatalf("unable to send blockbeat") } + aliceNotifier.WaitForConfRegistrationAndSend(t) + // We should get a local force close event from Alice as she // should be able to detect the close based on the commitment // outputs. From 8de352e2760e89f0781c4d44b1c39d1fd6d97ab7 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Wed, 1 Oct 2025 16:52:18 -0700 Subject: [PATCH 070/134] contractcourt: add unit tests for rbf re-org cases This set of new tests ensures that if have created N RBF variants of the coop close transaction, that any of then can confirm, and be re-org'd, with us detecting the final spend once it confirms deeploy enough. --- .../chain_watcher_coop_reorg_test.go | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 contractcourt/chain_watcher_coop_reorg_test.go diff --git a/contractcourt/chain_watcher_coop_reorg_test.go b/contractcourt/chain_watcher_coop_reorg_test.go new file mode 100644 index 000000000..0e0a55219 --- /dev/null +++ b/contractcourt/chain_watcher_coop_reorg_test.go @@ -0,0 +1,198 @@ +package contractcourt + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/wire" +) + +// TestChainWatcherCoopCloseReorg tests that the chain watcher properly handles +// a reorganization during cooperative close confirmation waiting. When a +// cooperative close transaction is reorganized out, the chain watcher should +// re-register for spend notifications and detect an alternative transaction. +func TestChainWatcherCoopCloseReorg(t *testing.T) { + t.Parallel() + + // Create test harness. + harness := newChainWatcherTestHarness(t) + + // Create two cooperative close transactions with different fees. + tx1 := harness.createCoopCloseTx(5000) + tx2 := harness.createCoopCloseTx(4900) + + // Run cooperative close flow with reorg. + closeInfo := harness.runCoopCloseFlow(tx1, true, 2, tx2) + + // Assert that the second transaction was confirmed. + harness.assertCoopCloseTx(closeInfo, tx2) +} + +// TestChainWatcherCoopCloseSameTransactionAfterReorg tests that if the same +// transaction re-confirms after a reorganization, it is properly handled. +func TestChainWatcherCoopCloseSameTransactionAfterReorg(t *testing.T) { + t.Parallel() + + harness := newChainWatcherTestHarness(t) + + // Create a single cooperative close transaction. + tx := harness.createCoopCloseTx(5000) + + // Run flow with the same tx confirming after the reorg. + closeInfo := harness.runCoopCloseFlow(tx, true, 2, tx) + + harness.assertCoopCloseTx(closeInfo, tx) +} + +// TestChainWatcherCoopCloseMultipleReorgs tests handling of multiple +// consecutive reorganizations during cooperative close confirmation. +func TestChainWatcherCoopCloseMultipleReorgs(t *testing.T) { + t.Parallel() + + // Create test harness. + harness := newChainWatcherTestHarness(t) + + // Create multiple cooperative close transactions with different fees. + txs := []*wire.MsgTx{ + harness.createCoopCloseTx(5000), + harness.createCoopCloseTx(4950), + harness.createCoopCloseTx(4900), + harness.createCoopCloseTx(4850), + } + + // Define reorg depths for each transition. + reorgDepths := []int32{1, 2, 3} + + // Run multiple reorg flow. + closeInfo := harness.runMultipleReorgFlow(txs, reorgDepths) + + // Assert that the final transaction was confirmed. + harness.assertCoopCloseTx(closeInfo, txs[3]) +} + +// TestChainWatcherCoopCloseReorgNoAlternative tests that if a cooperative +// close is reorganized out and no alternative transaction appears, the +// chain watcher continues waiting. +func TestChainWatcherCoopCloseReorgNoAlternative(t *testing.T) { + t.Parallel() + + // Create test harness. + harness := newChainWatcherTestHarness(t) + + // Create a cooperative close transaction. + tx := harness.createCoopCloseTx(5000) + + // Send spend and wait for confirmation registration. + harness.sendSpend(tx) + harness.waitForConfRegistration() + + // Trigger reorg after some confirmations. + harness.mineBlocks(2) + harness.triggerReorg(tx, 2) + + // Assert no cooperative close event is received. + harness.assertNoCoopClose(2 * time.Second) + + // Now send a new transaction after the timeout. + harness.waitForSpendRegistration() + newTx := harness.createCoopCloseTx(4900) + harness.sendSpend(newTx) + harness.waitForConfRegistration() + harness.mineBlocks(1) + harness.confirmTx(newTx, harness.currentHeight) + + // Should receive cooperative close for the new transaction. + closeInfo := harness.waitForCoopClose(5 * time.Second) + harness.assertCoopCloseTx(closeInfo, newTx) +} + +// TestChainWatcherCoopCloseScaledConfirmationsWithReorg tests that scaled +// confirmations (based on channel capacity) work correctly with reorgs. +func TestChainWatcherCoopCloseScaledConfirmationsWithReorg(t *testing.T) { + t.Parallel() + + // Test with different confirmation requirements and reorg depths. + // Note: We start at 3 confirmations because 1-conf uses the fast path + // which bypasses reorg protection (it dispatches immediately). + testCases := []struct { + name string + requiredConfs uint32 + reorgDepth int32 + }{ + { + name: "triple_conf", + requiredConfs: 3, + reorgDepth: 2, + }, + { + name: "six_conf", + requiredConfs: 6, + reorgDepth: 4, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Create harness with specific confirmation + // requirements. + harness := newChainWatcherTestHarness( + t, withRequiredConfs(tc.requiredConfs), + ) + + // Create transactions. + tx1 := harness.createCoopCloseTx(5000) + tx2 := harness.createCoopCloseTx(4900) + + // Run with reorg at different depths based on capacity. + closeInfo := harness.runCoopCloseFlow( + tx1, true, tc.reorgDepth, tx2, + ) + + // Verify correct transaction confirmed. + harness.assertCoopCloseTx(closeInfo, tx2) + }) + } +} + +// TestChainWatcherCoopCloseRapidReorgs tests that the chain watcher handles +// multiple rapid reorgs in succession without getting into a broken state. +func TestChainWatcherCoopCloseRapidReorgs(t *testing.T) { + t.Parallel() + + // Create test harness. + harness := newChainWatcherTestHarness(t) + + // Create a cooperative close transaction. + tx := harness.createCoopCloseTx(5000) + + // Send spend notification. + harness.sendSpend(tx) + + // Trigger multiple rapid reorgs to stress the state machine. + for i := 0; i < 5; i++ { + harness.waitForConfRegistration() + harness.mineBlocks(1) + harness.triggerReorg(tx, int32(i+1)) + if i < 4 { + // Re-register for spend after each reorg except the + // last. + harness.waitForSpendRegistration() + harness.sendSpend(tx) + } + } + + // After stress, send a clean transaction. + harness.waitForSpendRegistration() + cleanTx := harness.createCoopCloseTx(4800) + harness.sendSpend(cleanTx) + harness.waitForConfRegistration() + harness.mineBlocks(1) + harness.confirmTx(cleanTx, harness.currentHeight) + + // Should still receive the cooperative close. + closeInfo := harness.waitForCoopClose(10 * time.Second) + harness.assertCoopCloseTx(closeInfo, cleanTx) +} From 25b19461db79b9ee5c1a1282a1933bf8bf51a4b8 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Wed, 1 Oct 2025 16:55:26 -0700 Subject: [PATCH 071/134] contractcourt: add generic close re-org tests In this commit, we add a set of generic close re-org tests. The most important test is the property based test, they will randomly confirm transactions, generate a re-org, then assert that eventually we dtect the final version. --- contractcourt/chain_watcher_reorg_test.go | 404 ++++++++++++++++++++++ 1 file changed, 404 insertions(+) create mode 100644 contractcourt/chain_watcher_reorg_test.go diff --git a/contractcourt/chain_watcher_reorg_test.go b/contractcourt/chain_watcher_reorg_test.go new file mode 100644 index 000000000..141eda571 --- /dev/null +++ b/contractcourt/chain_watcher_reorg_test.go @@ -0,0 +1,404 @@ +package contractcourt + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/wire" + "pgregory.net/rapid" +) + +// closeType represents the type of channel close for testing purposes. +type closeType int + +const ( + // closeTypeCoop represents a cooperative channel close. + closeTypeCoop closeType = iota + + // closeTypeRemoteUnilateral represents a remote unilateral close + // (remote party broadcasting their commitment). + closeTypeRemoteUnilateral + + // closeTypeLocalForce represents a local force close (us broadcasting + // our commitment). + closeTypeLocalForce + + // closeTypeBreach represents a breach (remote party broadcasting a + // revoked commitment). + closeTypeBreach +) + +// String returns a string representation of the close type. +func (c closeType) String() string { + switch c { + case closeTypeCoop: + return "cooperative" + case closeTypeRemoteUnilateral: + return "remote_unilateral" + case closeTypeLocalForce: + return "local_force" + case closeTypeBreach: + return "breach" + default: + return "unknown" + } +} + +// createCloseTx creates a close transaction of the specified type using the +// harness. +func createCloseTx(h *chainWatcherTestHarness, ct closeType, + outputValue int64) *wire.MsgTx { + + switch ct { + case closeTypeCoop: + return h.createCoopCloseTx(outputValue) + case closeTypeRemoteUnilateral: + return h.createRemoteForceCloseTx() + case closeTypeLocalForce: + return h.createLocalForceCloseTx() + case closeTypeBreach: + return h.createBreachCloseTx() + default: + h.t.Fatalf("unknown close type: %v", ct) + return nil + } +} + +// waitForCloseEvent waits for the appropriate close event based on close type. +func waitForCloseEvent(h *chainWatcherTestHarness, ct closeType, + timeout time.Duration) any { + + switch ct { + case closeTypeCoop: + return h.waitForCoopClose(timeout) + case closeTypeRemoteUnilateral: + return h.waitForRemoteUnilateralClose(timeout) + case closeTypeLocalForce: + return h.waitForLocalUnilateralClose(timeout) + case closeTypeBreach: + return h.waitForBreach(timeout) + default: + h.t.Fatalf("unknown close type: %v", ct) + return nil + } +} + +// assertCloseEventTx asserts that the close event matches the expected +// transaction based on close type. +func assertCloseEventTx(h *chainWatcherTestHarness, ct closeType, + event any, expectedTx *wire.MsgTx) { + + switch ct { + case closeTypeCoop: + coopInfo, ok := event.(*CooperativeCloseInfo) + if !ok { + h.t.Fatalf("expected CooperativeCloseInfo, got %T", + event) + } + h.assertCoopCloseTx(coopInfo, expectedTx) + + case closeTypeRemoteUnilateral: + remoteInfo, ok := event.(*RemoteUnilateralCloseInfo) + if !ok { + h.t.Fatalf("expected RemoteUnilateralCloseInfo, got %T", + event) + } + h.assertRemoteUnilateralCloseTx(remoteInfo, expectedTx) + + case closeTypeLocalForce: + localInfo, ok := event.(*LocalUnilateralCloseInfo) + if !ok { + h.t.Fatalf("expected LocalUnilateralCloseInfo, got %T", + event) + } + h.assertLocalUnilateralCloseTx(localInfo, expectedTx) + + case closeTypeBreach: + breachInfo, ok := event.(*BreachCloseInfo) + if !ok { + h.t.Fatalf("expected BreachCloseInfo, got %T", event) + } + h.assertBreachTx(breachInfo, expectedTx) + + default: + h.t.Fatalf("unknown close type: %v", ct) + } +} + +// generateAltTxsForReorgs generates alternative transactions for reorg +// scenarios. For commitment-based closes (breach, remote/local force), the same +// tx is reused since we can only have one commitment tx per channel state. For +// coop closes, new transactions with different output values are created. +func generateAltTxsForReorgs(h *chainWatcherTestHarness, ct closeType, + originalTx *wire.MsgTx, numReorgs int, sameTxAtEnd bool) []*wire.MsgTx { + + altTxs := make([]*wire.MsgTx, numReorgs) + + for i := 0; i < numReorgs; i++ { + switch ct { + case closeTypeBreach, closeTypeRemoteUnilateral, + closeTypeLocalForce: + + // Non-coop closes can only have one commitment tx, so + // all reorgs use the same transaction. + altTxs[i] = originalTx + + case closeTypeCoop: + if i == numReorgs-1 && sameTxAtEnd { + // Last reorg goes back to original transaction. + altTxs[i] = originalTx + } else { + // Create different coop close tx with different + // output value to make it unique. + outputValue := int64(5000 - (i+1)*100) + altTxs[i] = createCloseTx(h, ct, outputValue) + } + } + } + + return altTxs +} + +// testReorgProperties is the main property-based test for reorg handling +// across all close types. +// +// The testingT parameter is captured from the outer test function and used +// for operations that require *testing.T (like channel creation), while the +// rapid.T is used for all test reporting and property generation. +func testReorgProperties(testingT *testing.T) func(*rapid.T) { + return func(t *rapid.T) { + // Generate random close type. + allCloseTypes := []closeType{ + closeTypeCoop, + closeTypeRemoteUnilateral, + closeTypeLocalForce, + closeTypeBreach, + } + ct := rapid.SampledFrom(allCloseTypes).Draw(t, "closeType") + + // Generate random number of required confirmations (2-6). We + // use at least 2 so we have room for reorgs during + // confirmation. + requiredConfs := rapid.IntRange(2, 6).Draw(t, "requiredConfs") + + // Generate number of reorgs (1-3 to keep test runtime + // reasonable). + numReorgs := rapid.IntRange(1, 3).Draw(t, "numReorgs") + + // Generate whether the final transaction is the same as the + // original. + sameTxAtEnd := rapid.Bool().Draw(t, "sameTxAtEnd") + + // Log test parameters for debugging. + t.Logf("Testing %s close with %d confs, %d reorgs, "+ + "sameTxAtEnd=%v", + ct, requiredConfs, numReorgs, sameTxAtEnd) + + // Create test harness using both the concrete *testing.T for + // channel creation and the rapid.T for test reporting. + harness := newChainWatcherTestHarnessFromReporter( + testingT, t, withRequiredConfs(uint32(requiredConfs)), + ) + + // Create initial transaction. + tx1 := createCloseTx(harness, ct, 5000) + + // Generate alternative transactions for each reorg. + altTxs := generateAltTxsForReorgs( + harness, ct, tx1, numReorgs, sameTxAtEnd, + ) + + // Send the initial spend. + harness.sendSpend(tx1) + harness.waitForConfRegistration() + + // Execute the set of re-orgs, based on our random sample, we'll + // mine N blocks, do a re-org of size N, then wait for + // detection, and repeat. + for i := 0; i < numReorgs; i++ { + // Generate random reorg depth (1 to requiredConfs-1). + // We cap it to avoid reorging too far back. + reorgDepth := rapid.IntRange( + 1, requiredConfs-1, + ).Draw(t, "reorgDepth") + + // Mine some blocks (but less than required confs). + blocksToMine := rapid.IntRange( + 1, requiredConfs-1, + ).Draw(t, "blocksToMine") + harness.mineBlocks(int32(blocksToMine)) + + // Trigger reorg. + if i == 0 { + harness.triggerReorg( + tx1, int32(reorgDepth), + ) + } else { + harness.triggerReorg( + altTxs[i-1], int32(reorgDepth), + ) + } + + harness.waitForSpendRegistration() + + harness.sendSpend(altTxs[i]) + harness.waitForConfRegistration() + } + + // Mine enough blocks to confirm final transaction. + harness.mineBlocks(1) + finalTx := altTxs[numReorgs-1] + harness.confirmTx(finalTx, harness.currentHeight) + + // Wait for and verify close event. + event := waitForCloseEvent(harness, ct, 10*time.Second) + assertCloseEventTx(harness, ct, event, finalTx) + } +} + +// TestChainWatcherReorgAllCloseTypes runs property-based tests for reorg +// handling across all channel close types. It generates random combinations of +// the following: +// - Close type (coop, remote unilateral, local force, breach) +// - Number of confirmations required (2-6) +// - Number of reorgs (1-3) +// - Whether the final tx is same as original or different. +func TestChainWatcherReorgAllCloseTypes(t *testing.T) { + t.Parallel() + + rapid.Check(t, testReorgProperties(t)) +} + +// TestRemoteUnilateralCloseWithSingleReorg tests that a remote unilateral +// close is properly handled when a single reorg occurs during confirmation. +func TestRemoteUnilateralCloseWithSingleReorg(t *testing.T) { + t.Parallel() + + harness := newChainWatcherTestHarness(t) + + // Create two remote unilateral close transactions. + // Since these are commitment transactions, we can only have one per + // state, so we'll use the current one as tx1. + tx1 := harness.createRemoteForceCloseTx() + + // Advance channel state to get a different commitment. + _ = harness.createBreachCloseTx() + tx2 := harness.createRemoteForceCloseTx() + + // Send initial spend. + harness.sendSpend(tx1) + harness.waitForConfRegistration() + + // Mine a block and trigger reorg. + harness.mineBlocks(1) + harness.triggerReorg(tx1, 1) + + // Send alternative transaction after reorg. + harness.waitForSpendRegistration() + harness.sendSpend(tx2) + harness.waitForConfRegistration() + harness.mineBlocks(1) + harness.confirmTx(tx2, harness.currentHeight) + + // Verify correct event. + closeInfo := harness.waitForRemoteUnilateralClose(5 * time.Second) + harness.assertRemoteUnilateralCloseTx(closeInfo, tx2) +} + +// TestLocalForceCloseWithMultipleReorgs tests that a local force close is +// properly handled through multiple consecutive reorgs. +func TestLocalForceCloseWithMultipleReorgs(t *testing.T) { + t.Parallel() + + harness := newChainWatcherTestHarness(t) + + // For local force close, we can only broadcast our current commitment. + // We'll simulate multiple reorgs where the same tx keeps getting + // reorganized out and re-broadcast. + tx := harness.createLocalForceCloseTx() + + // First spend and reorg. + harness.sendSpend(tx) + harness.waitForConfRegistration() + harness.mineBlocks(1) + harness.triggerReorg(tx, 1) + + // Second spend and reorg. + harness.waitForSpendRegistration() + harness.sendSpend(tx) + harness.waitForConfRegistration() + harness.mineBlocks(1) + harness.triggerReorg(tx, 1) + + // Third spend - this one confirms. + harness.waitForSpendRegistration() + harness.sendSpend(tx) + harness.waitForConfRegistration() + harness.mineBlocks(1) + harness.confirmTx(tx, harness.currentHeight) + + // Verify correct event. + closeInfo := harness.waitForLocalUnilateralClose(5 * time.Second) + harness.assertLocalUnilateralCloseTx(closeInfo, tx) +} + +// TestBreachCloseWithDeepReorg tests that a breach (revoked commitment) is +// properly detected after a deep reorganization. +func TestBreachCloseWithDeepReorg(t *testing.T) { + t.Parallel() + + harness := newChainWatcherTestHarness(t) + + // Create a revoked commitment transaction. + revokedTx := harness.createBreachCloseTx() + + // Send spend and wait for confirmation registration. + harness.sendSpend(revokedTx) + harness.waitForConfRegistration() + + // Mine several blocks and then trigger a deep reorg. + harness.mineBlocks(5) + harness.triggerReorg(revokedTx, 5) + + // Re-broadcast same transaction after reorg. + harness.waitForSpendRegistration() + harness.sendSpend(revokedTx) + harness.waitForConfRegistration() + harness.mineBlocks(1) + harness.confirmTx(revokedTx, harness.currentHeight) + + // Verify breach detection. + breachInfo := harness.waitForBreach(5 * time.Second) + harness.assertBreachTx(breachInfo, revokedTx) +} + +// TestCoopCloseReorgToForceClose tests the edge case where a cooperative +// close gets reorged out and is replaced by a force close. +func TestCoopCloseReorgToForceClose(t *testing.T) { + t.Parallel() + + harness := newChainWatcherTestHarness(t) + + // Create a cooperative close and a force close transaction. + coopTx := harness.createCoopCloseTx(5000) + forceTx := harness.createRemoteForceCloseTx() + + // Send cooperative close. + harness.sendSpend(coopTx) + harness.waitForConfRegistration() + + // Trigger reorg that removes coop close. + harness.mineBlocks(1) + harness.triggerReorg(coopTx, 1) + + // Send force close as alternative. + harness.waitForSpendRegistration() + harness.sendSpend(forceTx) + harness.waitForConfRegistration() + harness.mineBlocks(1) + harness.confirmTx(forceTx, harness.currentHeight) + + // Should receive remote unilateral close event, not coop close. + closeInfo := harness.waitForRemoteUnilateralClose(5 * time.Second) + harness.assertRemoteUnilateralCloseTx(closeInfo, forceTx) +} From fadfdecd0e7accdc39a45d1c48cdb76b3822a477 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Wed, 1 Oct 2025 16:55:48 -0700 Subject: [PATCH 072/134] itest: add new coop close rbf itest This ensures that during the RBF process, if one confirms, a re-org occurs, then another confirms, that we'll properly detect this case. --- itest/list_on_test.go | 4 + itest/lnd_coop_close_rbf_test.go | 174 +++++++++++++++++++++++++++++++ itest/lnd_funding_test.go | 18 +++- 3 files changed, 194 insertions(+), 2 deletions(-) diff --git a/itest/list_on_test.go b/itest/list_on_test.go index 92c6547b3..ec608c629 100644 --- a/itest/list_on_test.go +++ b/itest/list_on_test.go @@ -727,6 +727,10 @@ var allTestCases = []*lntest.TestCase{ Name: "rbf coop close disconnect", TestFunc: testRBFCoopCloseDisconnect, }, + { + Name: "coop close rbf with reorg", + TestFunc: testCoopCloseRBFWithReorg, + }, { Name: "bump fee low budget", TestFunc: testBumpFeeLowBudget, diff --git a/itest/lnd_coop_close_rbf_test.go b/itest/lnd_coop_close_rbf_test.go index 5f8b15d40..13e10c9f7 100644 --- a/itest/lnd_coop_close_rbf_test.go +++ b/itest/lnd_coop_close_rbf_test.go @@ -1,8 +1,13 @@ package itest import ( + "fmt" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lntest" + "github.com/lightningnetwork/lnd/lntest/wait" "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/stretchr/testify/require" ) @@ -153,3 +158,172 @@ func testRBFCoopCloseDisconnect(ht *lntest.HarnessTest) { // Disconnect Bob from Alice. ht.DisconnectNodes(alice, bob) } + +// testCoopCloseRBFWithReorg tests that when a cooperative close transaction +// is reorganized out during confirmation waiting, the system properly handles +// RBF replacements and re-registration for any spend of the funding output. +func testCoopCloseRBFWithReorg(ht *lntest.HarnessTest) { + // Skip this test for neutrino backend as we can't trigger reorgs. + if ht.IsNeutrinoBackend() { + ht.Skipf("skipping reorg test for neutrino backend") + } + + // Force cooperative close to require 3 confirmations for predictable + // testing. + const requiredConfs = 3 + rbfCoopFlags := []string{ + "--protocol.rbf-coop-close", + "--dev.force-channel-close-confs=3", + } + + // Set the fee estimate to 1sat/vbyte to ensure our RBF attempts work. + ht.SetFeeEstimate(250) + ht.SetFeeEstimateWithConf(250, 6) + + // Create two nodes with enough coins for a 50/50 channel. + cfgs := [][]string{rbfCoopFlags, rbfCoopFlags} + params := lntest.OpenChannelParams{ + Amt: btcutil.Amount(10_000_000), + PushAmt: btcutil.Amount(5_000_000), + } + chanPoints, nodes := ht.CreateSimpleNetwork(cfgs, params) + alice, bob := nodes[0], nodes[1] + chanPoint := chanPoints[0] + + // Initiate cooperative close with initial fee rate of 5 sat/vb. + initialFeeRate := chainfee.SatPerVByte(5) + _, aliceCloseUpdate := ht.CloseChannelAssertPending( + alice, chanPoint, false, + lntest.WithCoopCloseFeeRate(initialFeeRate), + lntest.WithLocalTxNotify(), + ) + + // Verify the initial close transaction is at the expected fee rate. + alicePendingUpdate := aliceCloseUpdate.GetClosePending() + require.NotNil(ht, aliceCloseUpdate) + require.Equal( + ht, int64(initialFeeRate), alicePendingUpdate.FeePerVbyte, + ) + + // Capture the initial close transaction from the mempool. + initialCloseTxid, err := chainhash.NewHash(alicePendingUpdate.Txid) + require.NoError(ht, err) + initialCloseTx := ht.AssertTxInMempool(*initialCloseTxid) + + // Create first RBF replacement before any mining. + firstRbfFeeRate := chainfee.SatPerVByte(10) + _, firstRbfUpdate := ht.CloseChannelAssertPending( + bob, chanPoint, false, + lntest.WithCoopCloseFeeRate(firstRbfFeeRate), + lntest.WithLocalTxNotify(), + ) + + // Capture the first RBF transaction. + closePending := firstRbfUpdate.GetClosePending() + firstRbfTxid, err := chainhash.NewHash(closePending.Txid) + require.NoError(ht, err) + firstRbfTx := ht.AssertTxInMempool(*firstRbfTxid) + + _, bestHeight := ht.GetBestBlock() + ht.Logf("Current block height: %d", bestHeight) + + // Mine n-1 blocks (2 blocks when requiring 3 confirmations) with the + // first RBF transaction. This is just shy of full confirmation. + block1 := ht.Miner().MineBlockWithTxes( + []*btcutil.Tx{btcutil.NewTx(firstRbfTx)}, + ) + + ht.Logf("Mined block %d with first RBF tx", bestHeight+1) + + block2 := ht.MineEmptyBlocks(1)[0] + + ht.Logf("Mined block %d", bestHeight+2) + + ht.Logf("Re-orging two blocks to remove first RBF tx") + + // Trigger a reorganization that removes the last 2 blocks. This is safe + // because we haven't reached full confirmation yet. + bestBlockHash := block2.Header.BlockHash() + require.NoError( + ht, ht.Miner().Client.InvalidateBlock(&bestBlockHash), + ) + bestBlockHash = block1.Header.BlockHash() + require.NoError( + ht, ht.Miner().Client.InvalidateBlock(&bestBlockHash), + ) + + _, bestHeight = ht.GetBestBlock() + ht.Logf("Re-orged to block height: %d", bestHeight) + + ht.Log("Mining blocks to surpass previous chain") + + // Mine 2 empty blocks to trigger the reorg on the nodes. + ht.MineEmptyBlocks(2) + + _, bestHeight = ht.GetBestBlock() + ht.Logf("Mined blocks to reach height: %d", bestHeight) + + // Now, instead of mining the second RBF, mine the INITIAL transaction + // to test that the system can handle any valid spend of the funding + // output. + block := ht.Miner().MineBlockWithTxes( + []*btcutil.Tx{btcutil.NewTx(initialCloseTx)}, + ) + ht.AssertTxInBlock(block, *initialCloseTxid) + + // Mine additional blocks to reach the required confirmations (3 total). + ht.MineEmptyBlocks(requiredConfs - 1) + + // Both parties should see that the channel is now fully closed on chain + // with the expected closing txid. + expectedClosingTxid := initialCloseTxid.String() + err = wait.NoError(func() error { + req := &lnrpc.ClosedChannelsRequest{} + aliceClosedChans := alice.RPC.ClosedChannels(req) + bobClosedChans := bob.RPC.ClosedChannels(req) + if len(aliceClosedChans.Channels) != 1 { + return fmt.Errorf("alice: expected 1 closed "+ + "chan, got %d", len(aliceClosedChans.Channels)) + } + if len(bobClosedChans.Channels) != 1 { + return fmt.Errorf("bob: expected 1 closed chan, got %d", + len(bobClosedChans.Channels)) + } + + // Verify both Alice and Bob have the expected closing txid. + aliceClosedChan := aliceClosedChans.Channels[0] + if aliceClosedChan.ClosingTxHash != expectedClosingTxid { + return fmt.Errorf("alice: expected closing txid %s, "+ + "got %s", + expectedClosingTxid, + aliceClosedChan.ClosingTxHash) + } + if aliceClosedChan.CloseType != + lnrpc.ChannelCloseSummary_COOPERATIVE_CLOSE { + + return fmt.Errorf("alice: expected cooperative "+ + "close, got %v", + aliceClosedChan.CloseType) + } + + bobClosedChan := bobClosedChans.Channels[0] + if bobClosedChan.ClosingTxHash != expectedClosingTxid { + return fmt.Errorf("bob: expected closing txid %s, "+ + "got %s", + expectedClosingTxid, + bobClosedChan.ClosingTxHash) + } + if bobClosedChan.CloseType != + lnrpc.ChannelCloseSummary_COOPERATIVE_CLOSE { + + return fmt.Errorf("bob: expected cooperative "+ + "close, got %v", + bobClosedChan.CloseType) + } + + return nil + }, defaultTimeout) + require.NoError(ht, err) + + ht.Logf("Successfully verified closing txid: %s", expectedClosingTxid) +} diff --git a/itest/lnd_funding_test.go b/itest/lnd_funding_test.go index b6734e032..2c1daf53d 100644 --- a/itest/lnd_funding_test.go +++ b/itest/lnd_funding_test.go @@ -1272,8 +1272,17 @@ func testChannelFundingWithUnstableUtxos(ht *lntest.HarnessTest) { // Make sure Carol sees her to_remote output from the force close tx. ht.AssertNumPendingSweeps(carol, 1) - // We need to wait for carol initiating the sweep of the to_remote - // output of chanPoint2. + // Wait for Carol's sweep transaction to appear in the mempool. Due to + // async confirmation notifications, there's a race between when the + // sweep is registered and when the sweeper processes the next block. + // The sweeper uses immediate=false, so it broadcasts on the next block + // after registration. Mine an empty block to trigger the broadcast. + ht.MineEmptyBlocks(1) + + // Now the sweep should be in the mempool. + ht.AssertNumTxsInMempool(1) + + // Now we should see the unconfirmed UTXO from the sweep. utxo := ht.AssertNumUTXOsUnconfirmed(carol, 1)[0] // We now try to open channel using the unconfirmed utxo. @@ -1329,6 +1338,11 @@ func testChannelFundingWithUnstableUtxos(ht *lntest.HarnessTest) { // Make sure Carol sees her to_remote output from the force close tx. ht.AssertNumPendingSweeps(carol, 1) + // Mine an empty block to trigger the sweep broadcast (same fix as + // above). + ht.MineEmptyBlocks(1) + ht.AssertNumTxsInMempool(1) + // Wait for the to_remote sweep tx to show up in carol's wallet. ht.AssertNumUTXOsUnconfirmed(carol, 1) From 4adfa8ec39c7f262897f44b3a484e68340314178 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Thu, 30 Oct 2025 16:32:57 -0700 Subject: [PATCH 073/134] contractcourt: add sync dispatch fast-path for single confirmation closes In this commit, we add a fast-path optimization to the chain watcher's closeObserver that immediately dispatches close events when only a single confirmation is required (numConfs == 1). This addresses a timing issue with integration tests that were designed around the old synchronous blockbeat behavior, where close events were dispatched immediately upon spend detection. The recent async confirmation architecture (introduced in commit f6f716ab7) properly handles reorgs by waiting for N confirmations before dispatching close events. However, this created a race condition in integration tests that mine blocks synchronously and expect immediate close notifications. With the build tag setting numConfs to 1 for itests, the async confirmation notification could arrive after the test already started waiting for the close event, causing timeouts. We introduce a new handleSpendDispatch method that checks if numConfs == 1 and, if so, immediately calls handleCommitSpend to dispatch the close event synchronously, then returns true to skip the async state machine. This preserves the old behavior for integration tests while maintaining the full async reorg protection for production (where numConfs >= 3). The implementation adds the fast-path check in both spend detection paths (blockbeat and spend notification) to ensure consistent behavior regardless of which detects the spend first. We also update the affected unit tests to remove their expectation of confirmation registration, since the fast-path bypasses that step entirely. This approach optimizes for the integration test scenario without compromising production safety, as the fast-path only activates when a single confirmation is sufficient - a configuration that only exists in the controlled test environment. --- contractcourt/chain_watcher.go | 66 +++++++++++++++++++++++++++-- contractcourt/chain_watcher_test.go | 14 +++--- 2 files changed, 69 insertions(+), 11 deletions(-) diff --git a/contractcourt/chain_watcher.go b/contractcourt/chain_watcher.go index 579339aaf..64023a6f3 100644 --- a/contractcourt/chain_watcher.go +++ b/contractcourt/chain_watcher.go @@ -693,6 +693,12 @@ func newChainSet(chanState *channeldb.OpenChannel) (*chainSet, error) { // - Pending (confNtfn != nil): Spend detected, waiting for N confirmations // // - Confirmed: Spend confirmed with N blocks, close has been processed +// +// For single-confirmation scenarios (numConfs == 1), we bypass the async state +// machine and immediately dispatch close events upon spend detection. This +// provides synchronous behavior for integration tests which expect immediate +// notifications. For multi-confirmation scenarios (production with numConfs +// >= 3), we use the full async state machine with reorg protection. func (c *chainWatcher) closeObserver() { defer c.wg.Done() @@ -712,7 +718,7 @@ func (c *chainWatcher) closeObserver() { } spendNtfn := c.fundingSpendNtfn - defer spendNtfn.Cancel() + defer func() { spendNtfn.Cancel() }() // We use these variables to implement a state machine to track the // state of the spend confirmation process: @@ -744,6 +750,7 @@ func (c *chainWatcher) closeObserver() { "duplicate spend detection for tx %v", c.cfg.chanState.FundingOutpoint, spend.SpenderTxHash) + return confNtfn, nil } @@ -803,7 +810,22 @@ func (c *chainWatcher) closeObserver() { continue } + // FAST PATH: Check if we should dispatch immediately + // for single-confirmation scenarios. + if c.handleSpendDispatch(spend, "blockbeat") { + if confNtfn != nil { + confNtfn.Cancel() + confNtfn = nil + } + pendingSpend = nil + continue + } + + // ASYNC PATH: Multiple confirmations (production). // STATE TRANSITION: None -> Pending (from blockbeat). + // We've detected a spend, but don't process it yet. + // Instead, register for confirmations to protect + // against shallow reorgs. log.Infof("ChannelPoint(%v): detected spend from "+ "blockbeat, transitioning to %v", c.cfg.chanState.FundingOutpoint, @@ -813,7 +835,7 @@ func (c *chainWatcher) closeObserver() { if err != nil { log.Errorf("Unable to handle spend "+ "detection: %v", err) - return + continue } pendingSpend = spend confNtfn = newConfNtfn @@ -826,6 +848,18 @@ func (c *chainWatcher) closeObserver() { return } + // FAST PATH: Check if we should dispatch immediately + // for single-confirmation scenarios. + if c.handleSpendDispatch(spend, "spend notification") { + if confNtfn != nil { + confNtfn.Cancel() + confNtfn = nil + } + pendingSpend = nil + continue + } + + // ASYNC PATH: Multiple confirmations (production). log.Infof("ChannelPoint(%v): detected spend from "+ "notification, transitioning to %v", c.cfg.chanState.FundingOutpoint, @@ -835,7 +869,7 @@ func (c *chainWatcher) closeObserver() { if err != nil { log.Errorf("Unable to handle spend "+ "detection: %v", err) - return + continue } pendingSpend = spend confNtfn = newConfNtfn @@ -894,6 +928,8 @@ func (c *chainWatcher) closeObserver() { return } + c.fundingSpendNtfn = spendNtfn + log.Infof("ChannelPoint(%v): re-registered for spend "+ "detection", c.cfg.chanState.FundingOutpoint) @@ -1584,6 +1620,30 @@ func deriveFundingPkScript(chanState *channeldb.OpenChannel) ([]byte, error) { return fundingPkScript, nil } +// handleSpendDispatch processes a detected spend. For single-confirmation +// scenarios (numConfs == 1), it immediately dispatches the close event and +// returns true. For multi-confirmation scenarios, it returns false, indicating +// the caller should proceed with the async state machine. +func (c *chainWatcher) handleSpendDispatch(spend *chainntnfs.SpendDetail, + source string) bool { + + numConfs := c.requiredConfsForSpend() + if numConfs == 1 { + log.Infof("ChannelPoint(%v): single confirmation mode, "+ + "dispatching immediately from %s", + c.cfg.chanState.FundingOutpoint, source) + + err := c.handleCommitSpend(spend) + if err != nil { + log.Errorf("Failed to handle commit spend: %v", err) + } + + return true + } + + return false +} + // handleCommitSpend takes a spending tx of the funding output and handles the // channel close based on the closure type. func (c *chainWatcher) handleCommitSpend( diff --git a/contractcourt/chain_watcher_test.go b/contractcourt/chain_watcher_test.go index c57859ca4..8275886a1 100644 --- a/contractcourt/chain_watcher_test.go +++ b/contractcourt/chain_watcher_test.go @@ -94,10 +94,9 @@ func TestChainWatcherRemoteUnilateralClose(t *testing.T) { t.Fatalf("unable to send blockbeat") } - // Wait for the chain watcher to register for confirmations and send - // the confirmation. Since we set chanCloseConfs to 1, one confirmation - // is sufficient. - aliceNotifier.WaitForConfRegistrationAndSend(t) + // With chanCloseConfs set to 1, the fast-path dispatches immediately + // without confirmation registration. The close event should arrive + // directly after processing the blockbeat. // We should get a new spend event over the remote unilateral close // event channel. @@ -231,10 +230,9 @@ func TestChainWatcherRemoteUnilateralClosePendingCommit(t *testing.T) { t.Fatalf("unable to send blockbeat") } - // Wait for the chain watcher to register for confirmations and send - // the confirmation. Since we set chanCloseConfs to 1, one confirmation - // is sufficient. - aliceNotifier.WaitForConfRegistrationAndSend(t) + // With chanCloseConfs set to 1, the fast-path dispatches immediately + // without confirmation registration. The close event should arrive + // directly after processing the blockbeat. // We should get a new spend event over the remote unilateral close // event channel. From ab7a002c945801afc11fbe000138bdb3d14ab2bb Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Mon, 22 Dec 2025 22:16:46 -0600 Subject: [PATCH 074/134] contractcourt: unify+simplify new re-org aware logic --- contractcourt/chain_watcher.go | 216 ++++++++++++++++----------------- 1 file changed, 108 insertions(+), 108 deletions(-) diff --git a/contractcourt/chain_watcher.go b/contractcourt/chain_watcher.go index 64023a6f3..d44ced553 100644 --- a/contractcourt/chain_watcher.go +++ b/contractcourt/chain_watcher.go @@ -683,6 +683,93 @@ func newChainSet(chanState *channeldb.OpenChannel) (*chainSet, error) { }, nil } +// spendProcessResult holds the results of processing a detected spend. +type spendProcessResult struct { + // pendingSpend is the spend to track (nil if fast-path was used). + pendingSpend *chainntnfs.SpendDetail + + // confNtfn is the confirmation notification (nil if fast-path or + // error). + confNtfn *chainntnfs.ConfirmationEvent +} + +// processDetectedSpend handles a newly detected spend from either blockbeat or +// spend notification. It determines whether to use the fast-path (single conf) +// or async-path (multiple confs), and returns the updated state. +// +// For single-confirmation mode (numConfs == 1), it immediately dispatches the +// close event and returns empty result. For multi-confirmation mode, it +// registers for confirmations and returns the new pending state. +func (c *chainWatcher) processDetectedSpend( + spend *chainntnfs.SpendDetail, source string, + currentPendingSpend *chainntnfs.SpendDetail, + currentConfNtfn *chainntnfs.ConfirmationEvent) spendProcessResult { + + // FAST PATH: Single confirmation mode dispatches immediately. + if c.handleSpendDispatch(spend, source) { + if currentConfNtfn != nil { + currentConfNtfn.Cancel() + } + + return spendProcessResult{} + } + + // ASYNC PATH: Multiple confirmations (production). + // STATE TRANSITION: None -> Pending. + log.Infof("ChannelPoint(%v): detected spend from %s, "+ + "transitioning to %v", c.cfg.chanState.FundingOutpoint, + source, spendStatePending) + + // Check for duplicate spend detection. + if currentPendingSpend != nil { + if *currentPendingSpend.SpenderTxHash == *spend.SpenderTxHash { + log.Debugf("ChannelPoint(%v): ignoring duplicate "+ + "spend detection for tx %v", + c.cfg.chanState.FundingOutpoint, + spend.SpenderTxHash) + + return spendProcessResult{ + pendingSpend: currentPendingSpend, + confNtfn: currentConfNtfn, + } + } + + // Different spend detected. Cancel existing confNtfn. + log.Warnf("ChannelPoint(%v): detected different spend tx %v, "+ + "replacing pending tx %v", + c.cfg.chanState.FundingOutpoint, + spend.SpenderTxHash, currentPendingSpend.SpenderTxHash) + + if currentConfNtfn != nil { + currentConfNtfn.Cancel() + } + } + + numConfs := c.requiredConfsForSpend() + txid := spend.SpenderTxHash + + newConfNtfn, err := c.cfg.notifier.RegisterConfirmationsNtfn( + txid, spend.SpendingTx.TxOut[0].PkScript, numConfs, + uint32(spend.SpendingHeight), + ) + if err != nil { + log.Errorf("Unable to register confirmations: %v", err) + + return spendProcessResult{ + pendingSpend: currentPendingSpend, + confNtfn: currentConfNtfn, + } + } + + log.Infof("ChannelPoint(%v): waiting for %d confirmations of "+ + "spend tx %v", c.cfg.chanState.FundingOutpoint, numConfs, txid) + + return spendProcessResult{ + pendingSpend: spend, + confNtfn: newConfNtfn, + } +} + // closeObserver is a dedicated goroutine that will watch for any closes of the // channel that it's watching on chain. It implements a state machine to handle // spend detection and confirmation with reorg protection. The states are: @@ -735,57 +822,6 @@ func (c *chainWatcher) closeObserver() { log.Infof("Close observer for ChannelPoint(%v) active", c.cfg.chanState.FundingOutpoint) - // handleSpendDetection processes a newly detected spend by registering - // for confirmations. Returns the new confNtfn or error. - handleSpendDetection := func( - spend *chainntnfs.SpendDetail, - ) (*chainntnfs.ConfirmationEvent, error) { - - // If we already have a pending spend, check if it's the same - // transaction. This can happen if both the spend notification - // and blockbeat detect the same spend. - if pendingSpend != nil { - if *pendingSpend.SpenderTxHash == *spend.SpenderTxHash { - log.Debugf("ChannelPoint(%v): ignoring "+ - "duplicate spend detection for tx %v", - c.cfg.chanState.FundingOutpoint, - spend.SpenderTxHash) - - return confNtfn, nil - } - - // Different spend detected. Cancel existing confNtfn - // and replace with new one. - log.Warnf("ChannelPoint(%v): detected different "+ - "spend tx %v, replacing pending tx %v", - c.cfg.chanState.FundingOutpoint, - spend.SpenderTxHash, - pendingSpend.SpenderTxHash) - - if confNtfn != nil { - confNtfn.Cancel() - } - } - - numConfs := c.requiredConfsForSpend() - txid := spend.SpenderTxHash - - newConfNtfn, err := c.cfg.notifier.RegisterConfirmationsNtfn( - txid, spend.SpendingTx.TxOut[0].PkScript, - numConfs, uint32(spend.SpendingHeight), - ) - if err != nil { - return nil, fmt.Errorf("register confirmations: %w", - err) - } - - log.Infof("ChannelPoint(%v): waiting for %d confirmations "+ - "of spend tx %v", c.cfg.chanState.FundingOutpoint, - numConfs, txid) - - return newConfNtfn, nil - } - for { // We only listen to confirmation channels when we have a // pending spend. By setting these to nil when not needed, Go's @@ -801,6 +837,9 @@ func (c *chainWatcher) closeObserver() { } select { + // A new block beat has just arrived, we'll handle the block + // beat, and see if it contains the spend of our funding + // transaction or not. case beat := <-c.BlockbeatChan: log.Debugf("ChainWatcher(%v) received blockbeat %v", c.cfg.chanState.FundingOutpoint, beat.Height()) @@ -810,73 +849,33 @@ func (c *chainWatcher) closeObserver() { continue } - // FAST PATH: Check if we should dispatch immediately - // for single-confirmation scenarios. - if c.handleSpendDispatch(spend, "blockbeat") { - if confNtfn != nil { - confNtfn.Cancel() - confNtfn = nil - } - pendingSpend = nil - continue - } + result := c.processDetectedSpend( + spend, "blockbeat", pendingSpend, confNtfn, + ) - // ASYNC PATH: Multiple confirmations (production). - // STATE TRANSITION: None -> Pending (from blockbeat). - // We've detected a spend, but don't process it yet. - // Instead, register for confirmations to protect - // against shallow reorgs. - log.Infof("ChannelPoint(%v): detected spend from "+ - "blockbeat, transitioning to %v", - c.cfg.chanState.FundingOutpoint, - spendStatePending) + pendingSpend = result.pendingSpend + confNtfn = result.confNtfn - newConfNtfn, err := handleSpendDetection(spend) - if err != nil { - log.Errorf("Unable to handle spend "+ - "detection: %v", err) - continue - } - pendingSpend = spend - confNtfn = newConfNtfn - - // STATE TRANSITION: None -> Pending. - // We've detected a spend, but don't process it yet. Instead, - // register for confirmations to protect against shallow reorgs. + // A direct spend was just detected, we'll process the new spend + // then see if we need to dispatch instantly, or wait around for + // additional confirmations. case spend, ok := <-spendNtfn.Spend: if !ok { return } - // FAST PATH: Check if we should dispatch immediately - // for single-confirmation scenarios. - if c.handleSpendDispatch(spend, "spend notification") { - if confNtfn != nil { - confNtfn.Cancel() - confNtfn = nil - } - pendingSpend = nil - continue - } + result := c.processDetectedSpend( + spend, "spend notification", pendingSpend, + confNtfn, + ) - // ASYNC PATH: Multiple confirmations (production). - log.Infof("ChannelPoint(%v): detected spend from "+ - "notification, transitioning to %v", - c.cfg.chanState.FundingOutpoint, - spendStatePending) + pendingSpend = result.pendingSpend + confNtfn = result.confNtfn - newConfNtfn, err := handleSpendDetection(spend) - if err != nil { - log.Errorf("Unable to handle spend "+ - "detection: %v", err) - continue - } - pendingSpend = spend - confNtfn = newConfNtfn - - // STATE TRANSITION: Pending -> Confirmed // The spend has reached required confirmations. It's now safe // to process since we've protected against shallow reorgs. + // + // * STATE TRANSITION: Pending -> Confirmed case conf, ok := <-confChan: if !ok { log.Errorf("Confirmation channel closed " + @@ -899,10 +898,11 @@ func (c *chainWatcher) closeObserver() { confNtfn = nil pendingSpend = nil - // STATE TRANSITION: Pending -> None // A reorg removed the spend tx. We reset to initial state and // wait for ANY new spend (could be the same tx re-mined, or a // different tx like an RBF replacement). + // + // * STATE TRANSITION: Pending -> None case reorgDepth, ok := <-negativeConfChan: if !ok { log.Errorf("Negative conf channel closed " + From 3a6a756e0a597c0ebb66f46dd6e059c3b0215229 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Tue, 6 Jan 2026 18:56:30 -0800 Subject: [PATCH 075/134] lncfg: increase DefaultIncomingBroadcastDelta to 16 With this change, we'll go to chain even earlier to ensure that we have enough time to sweep a potentially contested HTLC, now that we're waiting longer before sweeps to ensure that the commitment transaction is sufficeitnyl burried before we sweep. --- lncfg/config.go | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/lncfg/config.go b/lncfg/config.go index 178ef203b..c0ab51f26 100644 --- a/lncfg/config.go +++ b/lncfg/config.go @@ -20,11 +20,16 @@ const ( // DefaultIncomingBroadcastDelta defines the number of blocks before the // expiry of an incoming htlc at which we force close the channel. We // only go to chain if we also have the preimage to actually pull in the - // htlc. BOLT #2 suggests 7 blocks. We use a few more for extra safety. - // Within this window we need to get our sweep or 2nd level success tx - // confirmed, because after that the remote party is also able to claim - // the htlc using the timeout path. - DefaultIncomingBroadcastDelta = 10 + // htlc. BOLT #2 suggests 7 blocks. We use more for extra safety. + // + // The value accounts for: + // - Up to 6 blocks waiting for close tx confirmation (reorg safety) + // - Time to broadcast and confirm our sweep/2nd level success tx + // + // Within this window we need to get our sweep confirmed, because after + // that the remote party is also able to claim the htlc using the + // timeout path. + DefaultIncomingBroadcastDelta = 16 // DefaultFinalCltvRejectDelta defines the number of blocks before the // expiry of an incoming exit hop htlc at which we cancel it back From c12c9e7bd2321c55f8596464f54e6449c31da10a Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Thu, 8 Jan 2026 16:08:59 -0800 Subject: [PATCH 076/134] docs/release-notes: add release notes entry --- docs/release-notes/release-notes-0.20.1.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/release-notes/release-notes-0.20.1.md b/docs/release-notes/release-notes-0.20.1.md index 0d39f503e..36fa3da62 100644 --- a/docs/release-notes/release-notes-0.20.1.md +++ b/docs/release-notes/release-notes-0.20.1.md @@ -93,6 +93,13 @@ ensures dependencies are properly freed and logs the panic trace for debugging. +* [Improved confirmation scaling for cooperative + closes](https://github.com/lightningnetwork/lnd/pull/10331) to provide better + reorg protection. Previously, cooperative closes required a minimum of 3 + confirmations. Now, small channels only require 1 confirmation, while larger + channels scale proportionally using the standard 0.16 BTC threshold (matching + funding confirmation scaling). + ## RPC Updates * The `EstimateRouteFee` RPC now implements an [LSP detection @@ -107,6 +114,15 @@ ## Breaking Changes +* [Increased MinCLTVDelta from 18 to + 24](https://github.com/lightningnetwork/lnd/pull/TODO) to provide a larger + safety margin above the `DefaultFinalCltvRejectDelta` (19 blocks). This + affects users who create invoices with custom `cltv_expiry_delta` values + between 18-23, which will now require a minimum of 24. The default value of + 80 blocks for invoice creation remains unchanged, so most users will not be + affected. Existing invoices created before the upgrade will continue to work + normally. + ## Performance Improvements * [Added new Postgres configuration @@ -145,4 +161,5 @@ * Abdulkbk * bitromortac +* Olaoluwa Osuntokun * Ziggie From 8cf08fde1d7dd943ae3ce876bc752a02ae5c783f Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Tue, 13 Jan 2026 18:11:05 -0800 Subject: [PATCH 077/134] routing: increase MinCLTVDelta from 18 to 24 blocks This increases the minimum CLTV delta allowed for invoice creation to provide more headroom above DefaultFinalCltvRejectDelta (19 blocks). The previous value of 18 was below the reject threshold, which could allow users to create invoices with CLTV deltas that would be rejected when receiving payments. --- routing/router.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/routing/router.go b/routing/router.go index 3c35b7c52..19df5b921 100644 --- a/routing/router.go +++ b/routing/router.go @@ -54,8 +54,8 @@ const ( // creating incompatibilities during the upgrade process. For some time // LND has used an explicit default final CLTV delta of 40 blocks for // bitcoin, though we now clamp the lower end of this - // range for user-chosen deltas to 18 blocks to be conservative. - MinCLTVDelta = 18 + // range for user-chosen deltas to 24 blocks to be conservative. + MinCLTVDelta = 24 // MaxCLTVDelta is the maximum CLTV value accepted by LND for all // timelock deltas. From 82b4345a3e2a248733ad79cf3008c1a089918a10 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Tue, 13 Jan 2026 18:15:48 -0800 Subject: [PATCH 078/134] multi: increase min cltv delta to 24 --- itest/lnd_channel_policy_test.go | 2 +- itest/lnd_htlc_timeout_resolver_test.go | 4 ++-- itest/lnd_route_blinding_test.go | 4 ++-- itest/lnd_sweep_test.go | 2 +- lntest/harness.go | 10 +++++----- sample-lnd.conf | 2 +- zpay32/hophint.go | 2 +- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/itest/lnd_channel_policy_test.go b/itest/lnd_channel_policy_test.go index 7a333f073..7def317ba 100644 --- a/itest/lnd_channel_policy_test.go +++ b/itest/lnd_channel_policy_test.go @@ -295,7 +295,7 @@ func testUpdateChannelPolicy(ht *lntest.HarnessTest) { // propagated. baseFee = int64(800) feeRate = int64(123) - timeLockDelta = uint32(22) + timeLockDelta = uint32(24) maxHtlc *= 2 inboundBaseFee := int32(-400) inboundFeeRatePpm := int32(-60) diff --git a/itest/lnd_htlc_timeout_resolver_test.go b/itest/lnd_htlc_timeout_resolver_test.go index 25aa0afcc..271008625 100644 --- a/itest/lnd_htlc_timeout_resolver_test.go +++ b/itest/lnd_htlc_timeout_resolver_test.go @@ -14,8 +14,8 @@ import ( ) const ( - finalCltvDelta = routing.MinCLTVDelta // 18. - thawHeightDelta = finalCltvDelta * 2 // 36. + finalCltvDelta = routing.MinCLTVDelta // 24. + thawHeightDelta = finalCltvDelta * 2 // 48. ) // makeRouteHints creates a route hints that will allow Carol to be reached diff --git a/itest/lnd_route_blinding_test.go b/itest/lnd_route_blinding_test.go index af2612d24..b8339fb04 100644 --- a/itest/lnd_route_blinding_test.go +++ b/itest/lnd_route_blinding_test.go @@ -352,7 +352,7 @@ func (b *blindedForwardTest) setupNetwork(ctx context.Context, withInterceptor bool) { carolArgs := []string{ - "--bitcoin.timelockdelta=18", + "--bitcoin.timelockdelta=24", fmt.Sprintf("--bitcoin.defaultremotedelay=%v", toLocalCSV), } if withInterceptor { @@ -360,7 +360,7 @@ func (b *blindedForwardTest) setupNetwork(ctx context.Context, } daveArgs := []string{ - "--bitcoin.timelockdelta=18", + "--bitcoin.timelockdelta=24", fmt.Sprintf("--bitcoin.defaultremotedelay=%v", toLocalCSV), } cfgs := [][]string{nil, nil, carolArgs, daveArgs} diff --git a/itest/lnd_sweep_test.go b/itest/lnd_sweep_test.go index c5bcd3b15..3874786bf 100644 --- a/itest/lnd_sweep_test.go +++ b/itest/lnd_sweep_test.go @@ -879,7 +879,7 @@ func testSweepHTLCs(ht *lntest.HarnessTest) { // Before we mine empty blocks to check the RBF behavior, we need to be // aware that Bob's incoming HTLC will expire before his outgoing HTLC // deadline is reached. This happens because the incoming HTLC is sent - // onchain at CLTVDelta-BroadcastDelta=18-10=8, which means after 8 + // onchain at CLTVDelta-BroadcastDelta=24-16=8, which means after 8 // blocks are mined, we expect Bob force closes the channel Alice->Bob. blocksTillIncomingSweep := cltvDelta - lncfg.DefaultIncomingBroadcastDelta diff --git a/lntest/harness.go b/lntest/harness.go index 21c32a531..b56ac2a95 100644 --- a/lntest/harness.go +++ b/lntest/harness.go @@ -54,15 +54,15 @@ const ( // mining blocks. maxBlocksAllowed = 100 - finalCltvDelta = routing.MinCLTVDelta // 18. - thawHeightDelta = finalCltvDelta * 2 // 36. + finalCltvDelta = routing.MinCLTVDelta // 24. + thawHeightDelta = finalCltvDelta * 2 // 48. ) var ( // MaxBlocksMinedPerTest is the maximum number of blocks that we allow // a test to mine. This is an exported global variable so it can be // overwritten by other projects that don't have the same constraints. - MaxBlocksMinedPerTest = 50 + MaxBlocksMinedPerTest = 70 ) // TestCase defines a test case that's been used in the integration test. @@ -409,13 +409,13 @@ func (h *HarnessTest) checkAndLimitBlocksMined(startHeight int32) { desc += "1. break test into smaller individual tests, especially if " + "this is a table-drive test.\n" + "2. use smaller CSV via `--bitcoin.defaultremotedelay=1.`\n" + - "3. use smaller CLTV via `--bitcoin.timelockdelta=18.`\n" + + "3. use smaller CLTV via `--bitcoin.timelockdelta=24.`\n" + "4. remove unnecessary CloseChannel when test ends.\n" + "5. use `CreateSimpleNetwork` for efficient channel creation.\n" h.Log(desc) // We enforce that the test should not mine more than - // MaxBlocksMinedPerTest (50 by default) blocks, which is more than + // MaxBlocksMinedPerTest (70 by default) blocks, which is more than // enough to test a multi hop force close scenario. require.LessOrEqualf( h, int(blocksMined), MaxBlocksMinedPerTest, diff --git a/sample-lnd.conf b/sample-lnd.conf index f20035c86..eeb7a8123 100644 --- a/sample-lnd.conf +++ b/sample-lnd.conf @@ -1865,7 +1865,7 @@ ; DefaultIncomingBroadcastDelta set by lnd, otherwise the channel will be force ; closed anyway. A warning will be logged on startup if this value is not large ; enough to prevent force closes. -; invoices.holdexpirydelta=12 +; invoices.holdexpirydelta=18 [routing] diff --git a/zpay32/hophint.go b/zpay32/hophint.go index 07872b0d6..dd1a2eebe 100644 --- a/zpay32/hophint.go +++ b/zpay32/hophint.go @@ -12,7 +12,7 @@ const ( // We adhere to the recommendation in BOLT 02 for terminal payments. // See also: // https://github.com/lightning/bolts/blob/master/02-peer-protocol.md - DefaultAssumedFinalCLTVDelta = 18 + DefaultAssumedFinalCLTVDelta = 24 // feeRateParts is the total number of parts used to express fee rates. feeRateParts = 1e6 From a8b00fcb7033d805c6fe276fcf327bea68c14bb7 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Fri, 16 Jan 2026 17:04:44 -0800 Subject: [PATCH 079/134] build: bump version to v0.20.1 rc1 --- build/version.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build/version.go b/build/version.go index 04b5af784..b9ed3be11 100644 --- a/build/version.go +++ b/build/version.go @@ -47,11 +47,11 @@ const ( AppMinor uint = 20 // AppPatch defines the application patch for this binary. - AppPatch uint = 00 + AppPatch uint = 01 // AppPreRelease MUST only contain characters from semanticAlphabet per // the semantic versioning spec. - AppPreRelease = "beta" + AppPreRelease = "beta.rc1" ) func init() { From 175933a6ca7b1a14fdb1ba3393d29f843ed89559 Mon Sep 17 00:00:00 2001 From: ziggie Date: Wed, 21 Jan 2026 20:25:08 +0100 Subject: [PATCH 080/134] graphdb: reduce log noise from WRN to DBG (cherry picked from commit c78a75f5d880db8aa47255e7d1ab5e6ff1ba5195) --- graph/db/graph_cache.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/graph/db/graph_cache.go b/graph/db/graph_cache.go index 593483747..4a3a3b0f9 100644 --- a/graph/db/graph_cache.go +++ b/graph/db/graph_cache.go @@ -192,14 +192,14 @@ func (c *GraphCache) UpdatePolicy(policy *models.CachedEdgePolicy, fromNode, updatePolicy := func(nodeKey route.Vertex) { if len(c.nodeChannels[nodeKey]) == 0 { - log.Warnf("Node=%v not found in graph cache", nodeKey) + log.Debugf("Node=%v not found in graph cache", nodeKey) return } channel, ok := c.nodeChannels[nodeKey][policy.ChannelID] if !ok { - log.Warnf("Channel=%v not found in graph cache", + log.Debugf("Channel=%v not found in graph cache", policy.ChannelID) return From 1495920bb9e96d419437fcf85700ae74c52b697a Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Mon, 26 Jan 2026 13:36:14 -0400 Subject: [PATCH 081/134] Revert "Merge pull request #10510 from lightningnetwork/backport-10331-to-v0.20.x-branch" This reverts commit 40d8b475b5ac37dcf5262a306cd05cbb1df07271, reversing changes made to 0b9e8c33e1424522678c06dbf34c502f66e5f7cc. --- contractcourt/chain_arbitrator.go | 8 - contractcourt/chain_watcher.go | 364 ++-------- .../chain_watcher_coop_reorg_test.go | 198 ------ contractcourt/chain_watcher_reorg_test.go | 404 ----------- contractcourt/chain_watcher_test.go | 47 +- contractcourt/chain_watcher_test_harness.go | 656 ------------------ docs/release-notes/release-notes-0.20.1.md | 17 - itest/list_on_test.go | 4 - itest/lnd_channel_policy_test.go | 2 +- itest/lnd_coop_close_rbf_test.go | 174 ----- itest/lnd_funding_test.go | 18 +- itest/lnd_htlc_timeout_resolver_test.go | 4 +- itest/lnd_route_blinding_test.go | 4 +- itest/lnd_sweep_test.go | 2 +- lncfg/config.go | 15 +- lncfg/dev.go | 7 - lncfg/dev_integration.go | 12 - lntest/harness.go | 10 +- lntest/harness_assertion.go | 7 +- lntest/mock/chainnotifier.go | 40 +- lnwallet/confscale.go | 58 -- lnwallet/confscale_integration.go | 13 - lnwallet/confscale_prod.go | 25 - lnwallet/confscale_test.go | 340 --------- peer/brontide.go | 27 +- routing/router.go | 4 +- rpcserver.go | 9 +- sample-lnd.conf | 2 +- server.go | 52 +- zpay32/hophint.go | 2 +- 30 files changed, 119 insertions(+), 2406 deletions(-) delete mode 100644 contractcourt/chain_watcher_coop_reorg_test.go delete mode 100644 contractcourt/chain_watcher_reorg_test.go delete mode 100644 contractcourt/chain_watcher_test_harness.go delete mode 100644 lnwallet/confscale.go delete mode 100644 lnwallet/confscale_integration.go delete mode 100644 lnwallet/confscale_prod.go delete mode 100644 lnwallet/confscale_test.go diff --git a/contractcourt/chain_arbitrator.go b/contractcourt/chain_arbitrator.go index b4b2fa26f..05eb46a68 100644 --- a/contractcourt/chain_arbitrator.go +++ b/contractcourt/chain_arbitrator.go @@ -230,12 +230,6 @@ type ChainArbitratorConfig struct { // AuxResolver is an optional interface that can be used to modify the // way contracts are resolved. AuxResolver fn.Option[lnwallet.AuxContractResolver] - - // ChannelCloseConfs is an optional override for the number of - // confirmations required for channel closes. When set, this overrides - // the normal capacity-based scaling. This is only available in - // dev/integration builds for testing purposes. - ChannelCloseConfs fn.Option[uint32] } // ChainArbitrator is a sub-system that oversees the on-chain resolution of all @@ -1144,7 +1138,6 @@ func (c *ChainArbitrator) WatchNewChannel(newChan *channeldb.OpenChannel) error extractStateNumHint: lnwallet.GetStateNumHint, auxLeafStore: c.cfg.AuxLeafStore, auxResolver: c.cfg.AuxResolver, - chanCloseConfs: c.cfg.ChannelCloseConfs, }, ) if err != nil { @@ -1322,7 +1315,6 @@ func (c *ChainArbitrator) loadOpenChannels() error { extractStateNumHint: lnwallet.GetStateNumHint, auxLeafStore: c.cfg.AuxLeafStore, auxResolver: c.cfg.AuxResolver, - chanCloseConfs: c.cfg.ChannelCloseConfs, }, ) if err != nil { diff --git a/contractcourt/chain_watcher.go b/contractcourt/chain_watcher.go index d44ced553..082b47228 100644 --- a/contractcourt/chain_watcher.go +++ b/contractcourt/chain_watcher.go @@ -88,38 +88,6 @@ type BreachCloseInfo struct { CloseSummary channeldb.ChannelCloseSummary } -// spendConfirmationState represents the state of spend confirmation tracking -// in the closeObserver state machine. We wait for N confirmations before -// processing any spend to protect against shallow reorgs. -type spendConfirmationState uint8 - -const ( - // spendStateNone indicates no spend has been detected yet. - spendStateNone spendConfirmationState = iota - - // spendStatePending indicates a spend has been detected and we're - // waiting for the required number of confirmations. - spendStatePending - - // spendStateConfirmed indicates the spend has reached the required - // confirmations and has been processed. - spendStateConfirmed -) - -// String returns a human-readable representation of the state. -func (s spendConfirmationState) String() string { - switch s { - case spendStateNone: - return "None" - case spendStatePending: - return "Pending" - case spendStateConfirmed: - return "Confirmed" - default: - return "Unknown" - } -} - // CommitSet is a collection of the set of known valid commitments at a given // instant. If ConfCommitKey is set, then the commitment identified by the // HtlcSetKey has hit the chain. This struct will be used to examine all live @@ -261,12 +229,6 @@ type chainWatcherConfig struct { // auxResolver is used to supplement contract resolution. auxResolver fn.Option[lnwallet.AuxContractResolver] - - // chanCloseConfs is an optional override for the number of - // confirmations required for channel closes. When set, this overrides - // the normal capacity-based scaling. This is only available in - // dev/integration builds for testing purposes. - chanCloseConfs fn.Option[uint32] } // chainWatcher is a system that's assigned to every active channel. The duty @@ -683,263 +645,52 @@ func newChainSet(chanState *channeldb.OpenChannel) (*chainSet, error) { }, nil } -// spendProcessResult holds the results of processing a detected spend. -type spendProcessResult struct { - // pendingSpend is the spend to track (nil if fast-path was used). - pendingSpend *chainntnfs.SpendDetail - - // confNtfn is the confirmation notification (nil if fast-path or - // error). - confNtfn *chainntnfs.ConfirmationEvent -} - -// processDetectedSpend handles a newly detected spend from either blockbeat or -// spend notification. It determines whether to use the fast-path (single conf) -// or async-path (multiple confs), and returns the updated state. -// -// For single-confirmation mode (numConfs == 1), it immediately dispatches the -// close event and returns empty result. For multi-confirmation mode, it -// registers for confirmations and returns the new pending state. -func (c *chainWatcher) processDetectedSpend( - spend *chainntnfs.SpendDetail, source string, - currentPendingSpend *chainntnfs.SpendDetail, - currentConfNtfn *chainntnfs.ConfirmationEvent) spendProcessResult { - - // FAST PATH: Single confirmation mode dispatches immediately. - if c.handleSpendDispatch(spend, source) { - if currentConfNtfn != nil { - currentConfNtfn.Cancel() - } - - return spendProcessResult{} - } - - // ASYNC PATH: Multiple confirmations (production). - // STATE TRANSITION: None -> Pending. - log.Infof("ChannelPoint(%v): detected spend from %s, "+ - "transitioning to %v", c.cfg.chanState.FundingOutpoint, - source, spendStatePending) - - // Check for duplicate spend detection. - if currentPendingSpend != nil { - if *currentPendingSpend.SpenderTxHash == *spend.SpenderTxHash { - log.Debugf("ChannelPoint(%v): ignoring duplicate "+ - "spend detection for tx %v", - c.cfg.chanState.FundingOutpoint, - spend.SpenderTxHash) - - return spendProcessResult{ - pendingSpend: currentPendingSpend, - confNtfn: currentConfNtfn, - } - } - - // Different spend detected. Cancel existing confNtfn. - log.Warnf("ChannelPoint(%v): detected different spend tx %v, "+ - "replacing pending tx %v", - c.cfg.chanState.FundingOutpoint, - spend.SpenderTxHash, currentPendingSpend.SpenderTxHash) - - if currentConfNtfn != nil { - currentConfNtfn.Cancel() - } - } - - numConfs := c.requiredConfsForSpend() - txid := spend.SpenderTxHash - - newConfNtfn, err := c.cfg.notifier.RegisterConfirmationsNtfn( - txid, spend.SpendingTx.TxOut[0].PkScript, numConfs, - uint32(spend.SpendingHeight), - ) - if err != nil { - log.Errorf("Unable to register confirmations: %v", err) - - return spendProcessResult{ - pendingSpend: currentPendingSpend, - confNtfn: currentConfNtfn, - } - } - - log.Infof("ChannelPoint(%v): waiting for %d confirmations of "+ - "spend tx %v", c.cfg.chanState.FundingOutpoint, numConfs, txid) - - return spendProcessResult{ - pendingSpend: spend, - confNtfn: newConfNtfn, - } -} - // closeObserver is a dedicated goroutine that will watch for any closes of the -// channel that it's watching on chain. It implements a state machine to handle -// spend detection and confirmation with reorg protection. The states are: -// -// - None (confNtfn == nil): No spend detected yet, waiting for spend -// notification -// -// - Pending (confNtfn != nil): Spend detected, waiting for N confirmations -// -// - Confirmed: Spend confirmed with N blocks, close has been processed -// -// For single-confirmation scenarios (numConfs == 1), we bypass the async state -// machine and immediately dispatch close events upon spend detection. This -// provides synchronous behavior for integration tests which expect immediate -// notifications. For multi-confirmation scenarios (production with numConfs -// >= 3), we use the full async state machine with reorg protection. +// channel that it's watching on chain. In the event of an on-chain event, the +// close observer will assembled the proper materials required to claim the +// funds of the channel on-chain (if required), then dispatch these as +// notifications to all subscribers. func (c *chainWatcher) closeObserver() { defer c.wg.Done() - - registerForSpend := func() (*chainntnfs.SpendEvent, error) { - fundingPkScript, err := deriveFundingPkScript(c.cfg.chanState) - if err != nil { - return nil, err - } - - heightHint := c.cfg.chanState.DeriveHeightHint() - - return c.cfg.notifier.RegisterSpendNtfn( - &c.cfg.chanState.FundingOutpoint, - fundingPkScript, - heightHint, - ) - } - - spendNtfn := c.fundingSpendNtfn - defer func() { spendNtfn.Cancel() }() - - // We use these variables to implement a state machine to track the - // state of the spend confirmation process: - // * When confNtfn is nil, we're in state "None" waiting for a spend. - // * When confNtfn is set, we're in state "Pending" waiting for - // confirmations. - // - // After confirmations, we transition to state "Confirmed" and clean up. - var ( - pendingSpend *chainntnfs.SpendDetail - confNtfn *chainntnfs.ConfirmationEvent - ) + defer c.fundingSpendNtfn.Cancel() log.Infof("Close observer for ChannelPoint(%v) active", c.cfg.chanState.FundingOutpoint) for { - // We only listen to confirmation channels when we have a - // pending spend. By setting these to nil when not needed, Go's - // select ignores those cases, effectively implementing our - // state machine. - var ( - confChan <-chan *chainntnfs.TxConfirmation - negativeConfChan <-chan int32 - ) - if confNtfn != nil { - confChan = confNtfn.Confirmed - negativeConfChan = confNtfn.NegativeConf - } - select { - // A new block beat has just arrived, we'll handle the block - // beat, and see if it contains the spend of our funding - // transaction or not. + // A new block is received, we will check whether this block + // contains a spending tx that we are interested in. case beat := <-c.BlockbeatChan: log.Debugf("ChainWatcher(%v) received blockbeat %v", c.cfg.chanState.FundingOutpoint, beat.Height()) - spend := c.handleBlockbeat(beat) - if spend == nil { - continue - } + // Process the block. + c.handleBlockbeat(beat) - result := c.processDetectedSpend( - spend, "blockbeat", pendingSpend, confNtfn, - ) - - pendingSpend = result.pendingSpend - confNtfn = result.confNtfn - - // A direct spend was just detected, we'll process the new spend - // then see if we need to dispatch instantly, or wait around for - // additional confirmations. - case spend, ok := <-spendNtfn.Spend: + // If the funding outpoint is spent, we now go ahead and handle + // it. Note that we cannot rely solely on the `block` event + // above to trigger a close event, as deep down, the receiving + // of block notifications and the receiving of spending + // notifications are done in two different goroutines, so the + // expected order: [receive block -> receive spend] is not + // guaranteed . + case spend, ok := <-c.fundingSpendNtfn.Spend: + // If the channel was closed, then this means that the + // notifier exited, so we will as well. if !ok { return } - result := c.processDetectedSpend( - spend, "spend notification", pendingSpend, - confNtfn, - ) - - pendingSpend = result.pendingSpend - confNtfn = result.confNtfn - - // The spend has reached required confirmations. It's now safe - // to process since we've protected against shallow reorgs. - // - // * STATE TRANSITION: Pending -> Confirmed - case conf, ok := <-confChan: - if !ok { - log.Errorf("Confirmation channel closed " + - "unexpectedly") - return - } - - log.Infof("ChannelPoint(%v): spend confirmed at "+ - "height %d, transitioning to %v", - c.cfg.chanState.FundingOutpoint, - conf.BlockHeight, spendStateConfirmed) - - err := c.handleCommitSpend(pendingSpend) + err := c.handleCommitSpend(spend) if err != nil { - log.Errorf("Failed to handle confirmed "+ - "spend: %v", err) + log.Errorf("Failed to handle commit spend: %v", + err) } - confNtfn.Cancel() - confNtfn = nil - pendingSpend = nil - - // A reorg removed the spend tx. We reset to initial state and - // wait for ANY new spend (could be the same tx re-mined, or a - // different tx like an RBF replacement). - // - // * STATE TRANSITION: Pending -> None - case reorgDepth, ok := <-negativeConfChan: - if !ok { - log.Errorf("Negative conf channel closed " + - "unexpectedly") - return - } - - log.Infof("ChannelPoint(%v): spend reorged out at "+ - "depth %d, transitioning back to %v", - c.cfg.chanState.FundingOutpoint, reorgDepth, - spendStateNone) - - confNtfn.Cancel() - confNtfn = nil - pendingSpend = nil - - spendNtfn.Cancel() - var err error - spendNtfn, err = registerForSpend() - if err != nil { - log.Errorf("Unable to re-register for "+ - "spend: %v", err) - return - } - - c.fundingSpendNtfn = spendNtfn - - log.Infof("ChannelPoint(%v): re-registered for spend "+ - "detection", c.cfg.chanState.FundingOutpoint) - // The chainWatcher has been signalled to exit, so we'll do so // now. case <-c.quit: - if confNtfn != nil { - confNtfn.Cancel() - } - return } } @@ -1235,18 +986,6 @@ func (c *chainWatcher) toSelfAmount(tx *wire.MsgTx) btcutil.Amount { return btcutil.Amount(fn.Sum(vals)) } -// requiredConfsForSpend determines the number of confirmations required before -// processing a spend of the funding output. Uses config override if set -// (typically for testing), otherwise scales with channel capacity to balance -// security vs user experience for channels of different sizes. -func (c *chainWatcher) requiredConfsForSpend() uint32 { - return c.cfg.chanCloseConfs.UnwrapOrFunc(func() uint32 { - return lnwallet.CloseConfsForCapacity( - c.cfg.chanState.Capacity, - ) - }) -} - // dispatchCooperativeClose processed a detect cooperative channel closure. // We'll use the spending transaction to locate our output within the // transaction, then clean up the database state. We'll also dispatch a @@ -1264,8 +1003,8 @@ func (c *chainWatcher) dispatchCooperativeClose(commitSpend *chainntnfs.SpendDet localAmt := c.toSelfAmount(broadcastTx) // Once this is known, we'll mark the state as fully closed in the - // database. For cooperative closes, we wait for a confirmation depth - // determined by channel capacity before dispatching this event. + // database. We can do this as a cooperatively closed channel has all + // its outputs resolved after only one confirmation. closeSummary := &channeldb.ChannelCloseSummary{ ChanPoint: c.cfg.chanState.FundingOutpoint, ChainHash: c.cfg.chanState.ChainHash, @@ -1620,30 +1359,6 @@ func deriveFundingPkScript(chanState *channeldb.OpenChannel) ([]byte, error) { return fundingPkScript, nil } -// handleSpendDispatch processes a detected spend. For single-confirmation -// scenarios (numConfs == 1), it immediately dispatches the close event and -// returns true. For multi-confirmation scenarios, it returns false, indicating -// the caller should proceed with the async state machine. -func (c *chainWatcher) handleSpendDispatch(spend *chainntnfs.SpendDetail, - source string) bool { - - numConfs := c.requiredConfsForSpend() - if numConfs == 1 { - log.Infof("ChannelPoint(%v): single confirmation mode, "+ - "dispatching immediately from %s", - c.cfg.chanState.FundingOutpoint, source) - - err := c.handleCommitSpend(spend) - if err != nil { - log.Errorf("Failed to handle commit spend: %v", err) - } - - return true - } - - return false -} - // handleCommitSpend takes a spending tx of the funding output and handles the // channel close based on the closure type. func (c *chainWatcher) handleCommitSpend( @@ -1699,10 +1414,9 @@ func (c *chainWatcher) handleCommitSpend( case wire.MaxTxInSequenceNum: fallthrough case mempool.MaxRBFSequence: - // This is a cooperative close. Dispatch it directly - the - // confirmation waiting and reorg handling is done in the - // closeObserver state machine before we reach this point. - if err := c.dispatchCooperativeClose(commitSpend); err != nil { + // TODO(roasbeef): rare but possible, need itest case for + err := c.dispatchCooperativeClose(commitSpend) + if err != nil { return fmt.Errorf("handle coop close: %w", err) } @@ -1807,10 +1521,9 @@ func (c *chainWatcher) chanPointConfirmed() bool { } // handleBlockbeat takes a blockbeat and queries for a spending tx for the -// funding output. If found, it returns the spend details so closeObserver can -// process it. Returns nil if no spend was detected. -func (c *chainWatcher) handleBlockbeat( - beat chainio.Blockbeat) *chainntnfs.SpendDetail { +// funding output. If the spending tx is found, it will be handled based on the +// closure type. +func (c *chainWatcher) handleBlockbeat(beat chainio.Blockbeat) { // Notify the chain watcher has processed the block. defer c.NotifyBlockProcessed(beat, nil) @@ -1822,23 +1535,24 @@ func (c *chainWatcher) handleBlockbeat( // If the funding output hasn't confirmed in this block, we // will check it again in the next block. if !c.chanPointConfirmed() { - return nil + return } } // Perform a non-blocking read to check whether the funding output was - // spent. The actual spend handling is done in closeObserver's state - // machine to avoid blocking the block processing pipeline. + // spent. spend := c.checkFundingSpend() if spend == nil { log.Tracef("No spend found for ChannelPoint(%v) in block %v", c.cfg.chanState.FundingOutpoint, beat.Height()) - return nil + return } - log.Debugf("Detected spend of ChannelPoint(%v) in block %v", - c.cfg.chanState.FundingOutpoint, beat.Height()) - - return spend + // The funding output was spent, we now handle it by sending a close + // event to the channel arbitrator. + err := c.handleCommitSpend(spend) + if err != nil { + log.Errorf("Failed to handle commit spend: %v", err) + } } diff --git a/contractcourt/chain_watcher_coop_reorg_test.go b/contractcourt/chain_watcher_coop_reorg_test.go deleted file mode 100644 index 0e0a55219..000000000 --- a/contractcourt/chain_watcher_coop_reorg_test.go +++ /dev/null @@ -1,198 +0,0 @@ -package contractcourt - -import ( - "testing" - "time" - - "github.com/btcsuite/btcd/wire" -) - -// TestChainWatcherCoopCloseReorg tests that the chain watcher properly handles -// a reorganization during cooperative close confirmation waiting. When a -// cooperative close transaction is reorganized out, the chain watcher should -// re-register for spend notifications and detect an alternative transaction. -func TestChainWatcherCoopCloseReorg(t *testing.T) { - t.Parallel() - - // Create test harness. - harness := newChainWatcherTestHarness(t) - - // Create two cooperative close transactions with different fees. - tx1 := harness.createCoopCloseTx(5000) - tx2 := harness.createCoopCloseTx(4900) - - // Run cooperative close flow with reorg. - closeInfo := harness.runCoopCloseFlow(tx1, true, 2, tx2) - - // Assert that the second transaction was confirmed. - harness.assertCoopCloseTx(closeInfo, tx2) -} - -// TestChainWatcherCoopCloseSameTransactionAfterReorg tests that if the same -// transaction re-confirms after a reorganization, it is properly handled. -func TestChainWatcherCoopCloseSameTransactionAfterReorg(t *testing.T) { - t.Parallel() - - harness := newChainWatcherTestHarness(t) - - // Create a single cooperative close transaction. - tx := harness.createCoopCloseTx(5000) - - // Run flow with the same tx confirming after the reorg. - closeInfo := harness.runCoopCloseFlow(tx, true, 2, tx) - - harness.assertCoopCloseTx(closeInfo, tx) -} - -// TestChainWatcherCoopCloseMultipleReorgs tests handling of multiple -// consecutive reorganizations during cooperative close confirmation. -func TestChainWatcherCoopCloseMultipleReorgs(t *testing.T) { - t.Parallel() - - // Create test harness. - harness := newChainWatcherTestHarness(t) - - // Create multiple cooperative close transactions with different fees. - txs := []*wire.MsgTx{ - harness.createCoopCloseTx(5000), - harness.createCoopCloseTx(4950), - harness.createCoopCloseTx(4900), - harness.createCoopCloseTx(4850), - } - - // Define reorg depths for each transition. - reorgDepths := []int32{1, 2, 3} - - // Run multiple reorg flow. - closeInfo := harness.runMultipleReorgFlow(txs, reorgDepths) - - // Assert that the final transaction was confirmed. - harness.assertCoopCloseTx(closeInfo, txs[3]) -} - -// TestChainWatcherCoopCloseReorgNoAlternative tests that if a cooperative -// close is reorganized out and no alternative transaction appears, the -// chain watcher continues waiting. -func TestChainWatcherCoopCloseReorgNoAlternative(t *testing.T) { - t.Parallel() - - // Create test harness. - harness := newChainWatcherTestHarness(t) - - // Create a cooperative close transaction. - tx := harness.createCoopCloseTx(5000) - - // Send spend and wait for confirmation registration. - harness.sendSpend(tx) - harness.waitForConfRegistration() - - // Trigger reorg after some confirmations. - harness.mineBlocks(2) - harness.triggerReorg(tx, 2) - - // Assert no cooperative close event is received. - harness.assertNoCoopClose(2 * time.Second) - - // Now send a new transaction after the timeout. - harness.waitForSpendRegistration() - newTx := harness.createCoopCloseTx(4900) - harness.sendSpend(newTx) - harness.waitForConfRegistration() - harness.mineBlocks(1) - harness.confirmTx(newTx, harness.currentHeight) - - // Should receive cooperative close for the new transaction. - closeInfo := harness.waitForCoopClose(5 * time.Second) - harness.assertCoopCloseTx(closeInfo, newTx) -} - -// TestChainWatcherCoopCloseScaledConfirmationsWithReorg tests that scaled -// confirmations (based on channel capacity) work correctly with reorgs. -func TestChainWatcherCoopCloseScaledConfirmationsWithReorg(t *testing.T) { - t.Parallel() - - // Test with different confirmation requirements and reorg depths. - // Note: We start at 3 confirmations because 1-conf uses the fast path - // which bypasses reorg protection (it dispatches immediately). - testCases := []struct { - name string - requiredConfs uint32 - reorgDepth int32 - }{ - { - name: "triple_conf", - requiredConfs: 3, - reorgDepth: 2, - }, - { - name: "six_conf", - requiredConfs: 6, - reorgDepth: 4, - }, - } - - for _, tc := range testCases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - // Create harness with specific confirmation - // requirements. - harness := newChainWatcherTestHarness( - t, withRequiredConfs(tc.requiredConfs), - ) - - // Create transactions. - tx1 := harness.createCoopCloseTx(5000) - tx2 := harness.createCoopCloseTx(4900) - - // Run with reorg at different depths based on capacity. - closeInfo := harness.runCoopCloseFlow( - tx1, true, tc.reorgDepth, tx2, - ) - - // Verify correct transaction confirmed. - harness.assertCoopCloseTx(closeInfo, tx2) - }) - } -} - -// TestChainWatcherCoopCloseRapidReorgs tests that the chain watcher handles -// multiple rapid reorgs in succession without getting into a broken state. -func TestChainWatcherCoopCloseRapidReorgs(t *testing.T) { - t.Parallel() - - // Create test harness. - harness := newChainWatcherTestHarness(t) - - // Create a cooperative close transaction. - tx := harness.createCoopCloseTx(5000) - - // Send spend notification. - harness.sendSpend(tx) - - // Trigger multiple rapid reorgs to stress the state machine. - for i := 0; i < 5; i++ { - harness.waitForConfRegistration() - harness.mineBlocks(1) - harness.triggerReorg(tx, int32(i+1)) - if i < 4 { - // Re-register for spend after each reorg except the - // last. - harness.waitForSpendRegistration() - harness.sendSpend(tx) - } - } - - // After stress, send a clean transaction. - harness.waitForSpendRegistration() - cleanTx := harness.createCoopCloseTx(4800) - harness.sendSpend(cleanTx) - harness.waitForConfRegistration() - harness.mineBlocks(1) - harness.confirmTx(cleanTx, harness.currentHeight) - - // Should still receive the cooperative close. - closeInfo := harness.waitForCoopClose(10 * time.Second) - harness.assertCoopCloseTx(closeInfo, cleanTx) -} diff --git a/contractcourt/chain_watcher_reorg_test.go b/contractcourt/chain_watcher_reorg_test.go deleted file mode 100644 index 141eda571..000000000 --- a/contractcourt/chain_watcher_reorg_test.go +++ /dev/null @@ -1,404 +0,0 @@ -package contractcourt - -import ( - "testing" - "time" - - "github.com/btcsuite/btcd/wire" - "pgregory.net/rapid" -) - -// closeType represents the type of channel close for testing purposes. -type closeType int - -const ( - // closeTypeCoop represents a cooperative channel close. - closeTypeCoop closeType = iota - - // closeTypeRemoteUnilateral represents a remote unilateral close - // (remote party broadcasting their commitment). - closeTypeRemoteUnilateral - - // closeTypeLocalForce represents a local force close (us broadcasting - // our commitment). - closeTypeLocalForce - - // closeTypeBreach represents a breach (remote party broadcasting a - // revoked commitment). - closeTypeBreach -) - -// String returns a string representation of the close type. -func (c closeType) String() string { - switch c { - case closeTypeCoop: - return "cooperative" - case closeTypeRemoteUnilateral: - return "remote_unilateral" - case closeTypeLocalForce: - return "local_force" - case closeTypeBreach: - return "breach" - default: - return "unknown" - } -} - -// createCloseTx creates a close transaction of the specified type using the -// harness. -func createCloseTx(h *chainWatcherTestHarness, ct closeType, - outputValue int64) *wire.MsgTx { - - switch ct { - case closeTypeCoop: - return h.createCoopCloseTx(outputValue) - case closeTypeRemoteUnilateral: - return h.createRemoteForceCloseTx() - case closeTypeLocalForce: - return h.createLocalForceCloseTx() - case closeTypeBreach: - return h.createBreachCloseTx() - default: - h.t.Fatalf("unknown close type: %v", ct) - return nil - } -} - -// waitForCloseEvent waits for the appropriate close event based on close type. -func waitForCloseEvent(h *chainWatcherTestHarness, ct closeType, - timeout time.Duration) any { - - switch ct { - case closeTypeCoop: - return h.waitForCoopClose(timeout) - case closeTypeRemoteUnilateral: - return h.waitForRemoteUnilateralClose(timeout) - case closeTypeLocalForce: - return h.waitForLocalUnilateralClose(timeout) - case closeTypeBreach: - return h.waitForBreach(timeout) - default: - h.t.Fatalf("unknown close type: %v", ct) - return nil - } -} - -// assertCloseEventTx asserts that the close event matches the expected -// transaction based on close type. -func assertCloseEventTx(h *chainWatcherTestHarness, ct closeType, - event any, expectedTx *wire.MsgTx) { - - switch ct { - case closeTypeCoop: - coopInfo, ok := event.(*CooperativeCloseInfo) - if !ok { - h.t.Fatalf("expected CooperativeCloseInfo, got %T", - event) - } - h.assertCoopCloseTx(coopInfo, expectedTx) - - case closeTypeRemoteUnilateral: - remoteInfo, ok := event.(*RemoteUnilateralCloseInfo) - if !ok { - h.t.Fatalf("expected RemoteUnilateralCloseInfo, got %T", - event) - } - h.assertRemoteUnilateralCloseTx(remoteInfo, expectedTx) - - case closeTypeLocalForce: - localInfo, ok := event.(*LocalUnilateralCloseInfo) - if !ok { - h.t.Fatalf("expected LocalUnilateralCloseInfo, got %T", - event) - } - h.assertLocalUnilateralCloseTx(localInfo, expectedTx) - - case closeTypeBreach: - breachInfo, ok := event.(*BreachCloseInfo) - if !ok { - h.t.Fatalf("expected BreachCloseInfo, got %T", event) - } - h.assertBreachTx(breachInfo, expectedTx) - - default: - h.t.Fatalf("unknown close type: %v", ct) - } -} - -// generateAltTxsForReorgs generates alternative transactions for reorg -// scenarios. For commitment-based closes (breach, remote/local force), the same -// tx is reused since we can only have one commitment tx per channel state. For -// coop closes, new transactions with different output values are created. -func generateAltTxsForReorgs(h *chainWatcherTestHarness, ct closeType, - originalTx *wire.MsgTx, numReorgs int, sameTxAtEnd bool) []*wire.MsgTx { - - altTxs := make([]*wire.MsgTx, numReorgs) - - for i := 0; i < numReorgs; i++ { - switch ct { - case closeTypeBreach, closeTypeRemoteUnilateral, - closeTypeLocalForce: - - // Non-coop closes can only have one commitment tx, so - // all reorgs use the same transaction. - altTxs[i] = originalTx - - case closeTypeCoop: - if i == numReorgs-1 && sameTxAtEnd { - // Last reorg goes back to original transaction. - altTxs[i] = originalTx - } else { - // Create different coop close tx with different - // output value to make it unique. - outputValue := int64(5000 - (i+1)*100) - altTxs[i] = createCloseTx(h, ct, outputValue) - } - } - } - - return altTxs -} - -// testReorgProperties is the main property-based test for reorg handling -// across all close types. -// -// The testingT parameter is captured from the outer test function and used -// for operations that require *testing.T (like channel creation), while the -// rapid.T is used for all test reporting and property generation. -func testReorgProperties(testingT *testing.T) func(*rapid.T) { - return func(t *rapid.T) { - // Generate random close type. - allCloseTypes := []closeType{ - closeTypeCoop, - closeTypeRemoteUnilateral, - closeTypeLocalForce, - closeTypeBreach, - } - ct := rapid.SampledFrom(allCloseTypes).Draw(t, "closeType") - - // Generate random number of required confirmations (2-6). We - // use at least 2 so we have room for reorgs during - // confirmation. - requiredConfs := rapid.IntRange(2, 6).Draw(t, "requiredConfs") - - // Generate number of reorgs (1-3 to keep test runtime - // reasonable). - numReorgs := rapid.IntRange(1, 3).Draw(t, "numReorgs") - - // Generate whether the final transaction is the same as the - // original. - sameTxAtEnd := rapid.Bool().Draw(t, "sameTxAtEnd") - - // Log test parameters for debugging. - t.Logf("Testing %s close with %d confs, %d reorgs, "+ - "sameTxAtEnd=%v", - ct, requiredConfs, numReorgs, sameTxAtEnd) - - // Create test harness using both the concrete *testing.T for - // channel creation and the rapid.T for test reporting. - harness := newChainWatcherTestHarnessFromReporter( - testingT, t, withRequiredConfs(uint32(requiredConfs)), - ) - - // Create initial transaction. - tx1 := createCloseTx(harness, ct, 5000) - - // Generate alternative transactions for each reorg. - altTxs := generateAltTxsForReorgs( - harness, ct, tx1, numReorgs, sameTxAtEnd, - ) - - // Send the initial spend. - harness.sendSpend(tx1) - harness.waitForConfRegistration() - - // Execute the set of re-orgs, based on our random sample, we'll - // mine N blocks, do a re-org of size N, then wait for - // detection, and repeat. - for i := 0; i < numReorgs; i++ { - // Generate random reorg depth (1 to requiredConfs-1). - // We cap it to avoid reorging too far back. - reorgDepth := rapid.IntRange( - 1, requiredConfs-1, - ).Draw(t, "reorgDepth") - - // Mine some blocks (but less than required confs). - blocksToMine := rapid.IntRange( - 1, requiredConfs-1, - ).Draw(t, "blocksToMine") - harness.mineBlocks(int32(blocksToMine)) - - // Trigger reorg. - if i == 0 { - harness.triggerReorg( - tx1, int32(reorgDepth), - ) - } else { - harness.triggerReorg( - altTxs[i-1], int32(reorgDepth), - ) - } - - harness.waitForSpendRegistration() - - harness.sendSpend(altTxs[i]) - harness.waitForConfRegistration() - } - - // Mine enough blocks to confirm final transaction. - harness.mineBlocks(1) - finalTx := altTxs[numReorgs-1] - harness.confirmTx(finalTx, harness.currentHeight) - - // Wait for and verify close event. - event := waitForCloseEvent(harness, ct, 10*time.Second) - assertCloseEventTx(harness, ct, event, finalTx) - } -} - -// TestChainWatcherReorgAllCloseTypes runs property-based tests for reorg -// handling across all channel close types. It generates random combinations of -// the following: -// - Close type (coop, remote unilateral, local force, breach) -// - Number of confirmations required (2-6) -// - Number of reorgs (1-3) -// - Whether the final tx is same as original or different. -func TestChainWatcherReorgAllCloseTypes(t *testing.T) { - t.Parallel() - - rapid.Check(t, testReorgProperties(t)) -} - -// TestRemoteUnilateralCloseWithSingleReorg tests that a remote unilateral -// close is properly handled when a single reorg occurs during confirmation. -func TestRemoteUnilateralCloseWithSingleReorg(t *testing.T) { - t.Parallel() - - harness := newChainWatcherTestHarness(t) - - // Create two remote unilateral close transactions. - // Since these are commitment transactions, we can only have one per - // state, so we'll use the current one as tx1. - tx1 := harness.createRemoteForceCloseTx() - - // Advance channel state to get a different commitment. - _ = harness.createBreachCloseTx() - tx2 := harness.createRemoteForceCloseTx() - - // Send initial spend. - harness.sendSpend(tx1) - harness.waitForConfRegistration() - - // Mine a block and trigger reorg. - harness.mineBlocks(1) - harness.triggerReorg(tx1, 1) - - // Send alternative transaction after reorg. - harness.waitForSpendRegistration() - harness.sendSpend(tx2) - harness.waitForConfRegistration() - harness.mineBlocks(1) - harness.confirmTx(tx2, harness.currentHeight) - - // Verify correct event. - closeInfo := harness.waitForRemoteUnilateralClose(5 * time.Second) - harness.assertRemoteUnilateralCloseTx(closeInfo, tx2) -} - -// TestLocalForceCloseWithMultipleReorgs tests that a local force close is -// properly handled through multiple consecutive reorgs. -func TestLocalForceCloseWithMultipleReorgs(t *testing.T) { - t.Parallel() - - harness := newChainWatcherTestHarness(t) - - // For local force close, we can only broadcast our current commitment. - // We'll simulate multiple reorgs where the same tx keeps getting - // reorganized out and re-broadcast. - tx := harness.createLocalForceCloseTx() - - // First spend and reorg. - harness.sendSpend(tx) - harness.waitForConfRegistration() - harness.mineBlocks(1) - harness.triggerReorg(tx, 1) - - // Second spend and reorg. - harness.waitForSpendRegistration() - harness.sendSpend(tx) - harness.waitForConfRegistration() - harness.mineBlocks(1) - harness.triggerReorg(tx, 1) - - // Third spend - this one confirms. - harness.waitForSpendRegistration() - harness.sendSpend(tx) - harness.waitForConfRegistration() - harness.mineBlocks(1) - harness.confirmTx(tx, harness.currentHeight) - - // Verify correct event. - closeInfo := harness.waitForLocalUnilateralClose(5 * time.Second) - harness.assertLocalUnilateralCloseTx(closeInfo, tx) -} - -// TestBreachCloseWithDeepReorg tests that a breach (revoked commitment) is -// properly detected after a deep reorganization. -func TestBreachCloseWithDeepReorg(t *testing.T) { - t.Parallel() - - harness := newChainWatcherTestHarness(t) - - // Create a revoked commitment transaction. - revokedTx := harness.createBreachCloseTx() - - // Send spend and wait for confirmation registration. - harness.sendSpend(revokedTx) - harness.waitForConfRegistration() - - // Mine several blocks and then trigger a deep reorg. - harness.mineBlocks(5) - harness.triggerReorg(revokedTx, 5) - - // Re-broadcast same transaction after reorg. - harness.waitForSpendRegistration() - harness.sendSpend(revokedTx) - harness.waitForConfRegistration() - harness.mineBlocks(1) - harness.confirmTx(revokedTx, harness.currentHeight) - - // Verify breach detection. - breachInfo := harness.waitForBreach(5 * time.Second) - harness.assertBreachTx(breachInfo, revokedTx) -} - -// TestCoopCloseReorgToForceClose tests the edge case where a cooperative -// close gets reorged out and is replaced by a force close. -func TestCoopCloseReorgToForceClose(t *testing.T) { - t.Parallel() - - harness := newChainWatcherTestHarness(t) - - // Create a cooperative close and a force close transaction. - coopTx := harness.createCoopCloseTx(5000) - forceTx := harness.createRemoteForceCloseTx() - - // Send cooperative close. - harness.sendSpend(coopTx) - harness.waitForConfRegistration() - - // Trigger reorg that removes coop close. - harness.mineBlocks(1) - harness.triggerReorg(coopTx, 1) - - // Send force close as alternative. - harness.waitForSpendRegistration() - harness.sendSpend(forceTx) - harness.waitForConfRegistration() - harness.mineBlocks(1) - harness.confirmTx(forceTx, harness.currentHeight) - - // Should receive remote unilateral close event, not coop close. - closeInfo := harness.waitForRemoteUnilateralClose(5 * time.Second) - harness.assertRemoteUnilateralCloseTx(closeInfo, forceTx) -} diff --git a/contractcourt/chain_watcher_test.go b/contractcourt/chain_watcher_test.go index 8275886a1..2dc3605d3 100644 --- a/contractcourt/chain_watcher_test.go +++ b/contractcourt/chain_watcher_test.go @@ -12,7 +12,6 @@ import ( "github.com/lightningnetwork/lnd/chainio" "github.com/lightningnetwork/lnd/chainntnfs" "github.com/lightningnetwork/lnd/channeldb" - "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/input" lnmock "github.com/lightningnetwork/lnd/lntest/mock" "github.com/lightningnetwork/lnd/lnwallet" @@ -35,19 +34,16 @@ func TestChainWatcherRemoteUnilateralClose(t *testing.T) { // With the channels created, we'll now create a chain watcher instance // which will be watching for any closes of Alice's channel. - confRegistered := make(chan struct{}, 1) aliceNotifier := &lnmock.ChainNotifier{ - SpendChan: make(chan *chainntnfs.SpendDetail, 1), - EpochChan: make(chan *chainntnfs.BlockEpoch), - ConfChan: make(chan *chainntnfs.TxConfirmation, 1), - ConfRegistered: confRegistered, + SpendChan: make(chan *chainntnfs.SpendDetail, 1), + EpochChan: make(chan *chainntnfs.BlockEpoch), + ConfChan: make(chan *chainntnfs.TxConfirmation), } aliceChainWatcher, err := newChainWatcher(chainWatcherConfig{ chanState: aliceChannel.State(), notifier: aliceNotifier, signer: aliceChannel.Signer, extractStateNumHint: lnwallet.GetStateNumHint, - chanCloseConfs: fn.Some(uint32(1)), }) require.NoError(t, err, "unable to create chain watcher") err = aliceChainWatcher.Start() @@ -94,10 +90,6 @@ func TestChainWatcherRemoteUnilateralClose(t *testing.T) { t.Fatalf("unable to send blockbeat") } - // With chanCloseConfs set to 1, the fast-path dispatches immediately - // without confirmation registration. The close event should arrive - // directly after processing the blockbeat. - // We should get a new spend event over the remote unilateral close // event channel. var uniClose *RemoteUnilateralCloseInfo @@ -152,19 +144,16 @@ func TestChainWatcherRemoteUnilateralClosePendingCommit(t *testing.T) { // With the channels created, we'll now create a chain watcher instance // which will be watching for any closes of Alice's channel. - confRegistered := make(chan struct{}, 1) aliceNotifier := &lnmock.ChainNotifier{ - SpendChan: make(chan *chainntnfs.SpendDetail), - EpochChan: make(chan *chainntnfs.BlockEpoch), - ConfChan: make(chan *chainntnfs.TxConfirmation), - ConfRegistered: confRegistered, + SpendChan: make(chan *chainntnfs.SpendDetail), + EpochChan: make(chan *chainntnfs.BlockEpoch), + ConfChan: make(chan *chainntnfs.TxConfirmation), } aliceChainWatcher, err := newChainWatcher(chainWatcherConfig{ chanState: aliceChannel.State(), notifier: aliceNotifier, signer: aliceChannel.Signer, extractStateNumHint: lnwallet.GetStateNumHint, - chanCloseConfs: fn.Some(uint32(1)), }) require.NoError(t, err, "unable to create chain watcher") if err := aliceChainWatcher.Start(); err != nil { @@ -230,10 +219,6 @@ func TestChainWatcherRemoteUnilateralClosePendingCommit(t *testing.T) { t.Fatalf("unable to send blockbeat") } - // With chanCloseConfs set to 1, the fast-path dispatches immediately - // without confirmation registration. The close event should arrive - // directly after processing the blockbeat. - // We should get a new spend event over the remote unilateral close // event channel. var uniClose *RemoteUnilateralCloseInfo @@ -346,12 +331,10 @@ func TestChainWatcherDataLossProtect(t *testing.T) { // With the channels created, we'll now create a chain watcher // instance which will be watching for any closes of Alice's // channel. - confRegistered := make(chan struct{}, 1) aliceNotifier := &lnmock.ChainNotifier{ - SpendChan: make(chan *chainntnfs.SpendDetail), - EpochChan: make(chan *chainntnfs.BlockEpoch), - ConfChan: make(chan *chainntnfs.TxConfirmation), - ConfRegistered: confRegistered, + SpendChan: make(chan *chainntnfs.SpendDetail), + EpochChan: make(chan *chainntnfs.BlockEpoch), + ConfChan: make(chan *chainntnfs.TxConfirmation), } aliceChainWatcher, err := newChainWatcher(chainWatcherConfig{ chanState: aliceChanState, @@ -424,8 +407,6 @@ func TestChainWatcherDataLossProtect(t *testing.T) { t.Fatalf("unable to send blockbeat") } - aliceNotifier.WaitForConfRegistrationAndSend(t) - // We should get a new uni close resolution that indicates we // processed the DLP scenario. var uniClose *RemoteUnilateralCloseInfo @@ -551,12 +532,10 @@ func TestChainWatcherLocalForceCloseDetect(t *testing.T) { // With the channels created, we'll now create a chain watcher // instance which will be watching for any closes of Alice's // channel. - confRegistered := make(chan struct{}, 1) aliceNotifier := &lnmock.ChainNotifier{ - SpendChan: make(chan *chainntnfs.SpendDetail), - EpochChan: make(chan *chainntnfs.BlockEpoch), - ConfChan: make(chan *chainntnfs.TxConfirmation), - ConfRegistered: confRegistered, + SpendChan: make(chan *chainntnfs.SpendDetail), + EpochChan: make(chan *chainntnfs.BlockEpoch), + ConfChan: make(chan *chainntnfs.TxConfirmation), } aliceChainWatcher, err := newChainWatcher(chainWatcherConfig{ chanState: aliceChanState, @@ -625,8 +604,6 @@ func TestChainWatcherLocalForceCloseDetect(t *testing.T) { t.Fatalf("unable to send blockbeat") } - aliceNotifier.WaitForConfRegistrationAndSend(t) - // We should get a local force close event from Alice as she // should be able to detect the close based on the commitment // outputs. diff --git a/contractcourt/chain_watcher_test_harness.go b/contractcourt/chain_watcher_test_harness.go deleted file mode 100644 index 09ab03592..000000000 --- a/contractcourt/chain_watcher_test_harness.go +++ /dev/null @@ -1,656 +0,0 @@ -package contractcourt - -import ( - "testing" - "time" - - "github.com/btcsuite/btcd/chaincfg/chainhash" - "github.com/btcsuite/btcd/wire" - "github.com/lightningnetwork/lnd/chainntnfs" - "github.com/lightningnetwork/lnd/channeldb" - "github.com/lightningnetwork/lnd/fn/v2" - lnmock "github.com/lightningnetwork/lnd/lntest/mock" - "github.com/lightningnetwork/lnd/lnwallet" - "github.com/lightningnetwork/lnd/lnwire" -) - -// testReporter is a minimal interface for test reporting that is satisfied -// by both *testing.T and *rapid.T, allowing the harness to work with -// property-based tests. -type testReporter interface { - Helper() - Fatalf(format string, args ...any) -} - -// chainWatcherTestHarness provides a test harness for chain watcher tests -// with utilities for simulating spends, confirmations, and reorganizations. -type chainWatcherTestHarness struct { - t testReporter - - // aliceChannel and bobChannel are the test channels. - aliceChannel *lnwallet.LightningChannel - bobChannel *lnwallet.LightningChannel - - // chainWatcher is the chain watcher under test. - chainWatcher *chainWatcher - - // notifier is the mock chain notifier. - notifier *mockChainNotifier - - // chanEvents is the channel event subscription. - chanEvents *ChainEventSubscription - - // currentHeight tracks the current block height. - currentHeight int32 - - // blockbeatProcessed is a channel that signals when a blockbeat has - // been processed. - blockbeatProcessed chan struct{} -} - -// mockChainNotifier extends the standard mock with additional channels for -// testing cooperative close reorgs. -type mockChainNotifier struct { - *lnmock.ChainNotifier - - // confEvents tracks active confirmation event subscriptions. - confEvents []*mockConfirmationEvent - - // confRegistered is a channel that signals when a new confirmation - // event has been registered. - confRegistered chan struct{} - - // spendEvents tracks active spend event subscriptions. - spendEvents []*chainntnfs.SpendEvent - - // spendRegistered is a channel that signals when a new spend - // event has been registered. - spendRegistered chan struct{} -} - -// mockConfirmationEvent represents a mock confirmation event subscription. -type mockConfirmationEvent struct { - txid chainhash.Hash - numConfs uint32 - confirmedChan chan *chainntnfs.TxConfirmation - negConfChan chan int32 - cancelled bool -} - -// RegisterSpendNtfn creates a new mock spend event. -func (m *mockChainNotifier) RegisterSpendNtfn(outpoint *wire.OutPoint, - pkScript []byte, heightHint uint32) (*chainntnfs.SpendEvent, error) { - - // The base mock already has SpendChan, use that. - spendEvent := &chainntnfs.SpendEvent{ - Spend: m.SpendChan, - Cancel: func() { - // No-op for now. - }, - } - - m.spendEvents = append(m.spendEvents, spendEvent) - - // Signal that a new spend event has been registered. - select { - case m.spendRegistered <- struct{}{}: - default: - } - - return spendEvent, nil -} - -// RegisterConfirmationsNtfn creates a new mock confirmation event. -func (m *mockChainNotifier) RegisterConfirmationsNtfn(txid *chainhash.Hash, - pkScript []byte, numConfs, heightHint uint32, - opts ...chainntnfs.NotifierOption, -) (*chainntnfs.ConfirmationEvent, error) { - - mockEvent := &mockConfirmationEvent{ - txid: *txid, - numConfs: numConfs, - confirmedChan: make(chan *chainntnfs.TxConfirmation, 1), - negConfChan: make(chan int32, 1), - } - - m.confEvents = append(m.confEvents, mockEvent) - - // Signal that a new confirmation event has been registered. - select { - case m.confRegistered <- struct{}{}: - default: - } - - return &chainntnfs.ConfirmationEvent{ - Confirmed: mockEvent.confirmedChan, - NegativeConf: mockEvent.negConfChan, - Cancel: func() { - mockEvent.cancelled = true - }, - }, nil -} - -// harnessOpt is a functional option for configuring the test harness. -type harnessOpt func(*harnessConfig) - -// harnessConfig holds configuration for the test harness. -type harnessConfig struct { - requiredConfs fn.Option[uint32] -} - -// withRequiredConfs sets the number of confirmations required for channel -// closes. -func withRequiredConfs(confs uint32) harnessOpt { - return func(cfg *harnessConfig) { - cfg.requiredConfs = fn.Some(confs) - } -} - -// newChainWatcherTestHarness creates a new test harness for chain watcher -// tests. -func newChainWatcherTestHarness(t *testing.T, - opts ...harnessOpt) *chainWatcherTestHarness { - - return newChainWatcherTestHarnessFromReporter(t, t, opts...) -} - -// newChainWatcherTestHarnessFromReporter creates a test harness that works -// with both *testing.T and *rapid.T. The t parameter is used for -// operations that specifically require *testing.T (like CreateTestChannels), -// while reporter is used for all test reporting (Helper, Fatalf). -func newChainWatcherTestHarnessFromReporter(t *testing.T, - reporter testReporter, opts ...harnessOpt) *chainWatcherTestHarness { - - reporter.Helper() - - // Apply options. - cfg := &harnessConfig{ - requiredConfs: fn.None[uint32](), - } - for _, opt := range opts { - opt(cfg) - } - - // Create test channels. - aliceChannel, bobChannel, err := lnwallet.CreateTestChannels( - t, channeldb.SingleFunderTweaklessBit, - ) - if err != nil { - reporter.Fatalf("unable to create test channels: %v", err) - } - - // Create mock notifier. - baseNotifier := &lnmock.ChainNotifier{ - SpendChan: make(chan *chainntnfs.SpendDetail, 1), - EpochChan: make(chan *chainntnfs.BlockEpoch), - ConfChan: make(chan *chainntnfs.TxConfirmation, 1), - } - - notifier := &mockChainNotifier{ - ChainNotifier: baseNotifier, - confEvents: make([]*mockConfirmationEvent, 0), - confRegistered: make(chan struct{}, 10), - spendEvents: make([]*chainntnfs.SpendEvent, 0), - spendRegistered: make(chan struct{}, 10), - } - - // Create chain watcher. - chainWatcher, err := newChainWatcher(chainWatcherConfig{ - chanState: aliceChannel.State(), - notifier: notifier, - signer: aliceChannel.Signer, - extractStateNumHint: lnwallet.GetStateNumHint, - chanCloseConfs: cfg.requiredConfs, - contractBreach: func( - retInfo *lnwallet.BreachRetribution, - ) error { - // In tests, we just need to accept the breach - // notification. - return nil - }, - }) - if err != nil { - reporter.Fatalf("unable to create chain watcher: %v", err) - } - - // Start chain watcher (this will register for spend notification). - err = chainWatcher.Start() - if err != nil { - reporter.Fatalf("unable to start chain watcher: %v", err) - } - - // Subscribe to channel events. - chanEvents := chainWatcher.SubscribeChannelEvents() - - harness := &chainWatcherTestHarness{ - t: reporter, - aliceChannel: aliceChannel, - bobChannel: bobChannel, - chainWatcher: chainWatcher, - notifier: notifier, - chanEvents: chanEvents, - currentHeight: 100, - blockbeatProcessed: make(chan struct{}), - } - - // Wait for the initial spend registration that happens in Start(). - harness.waitForSpendRegistration() - - // Verify BlockbeatChan is initialized. - if chainWatcher.BlockbeatChan == nil { - reporter.Fatalf("BlockbeatChan is nil after initialization") - } - - // Register cleanup. We use the t for Cleanup since rapid.T - // may not have this method in the same way. - t.Cleanup(func() { - _ = chainWatcher.Stop() - }) - - return harness -} - -// createCoopCloseTx creates a cooperative close transaction with the given -// output value. The transaction will have the proper sequence number to -// indicate it's a cooperative close. -func (h *chainWatcherTestHarness) createCoopCloseTx( - outputValue int64) *wire.MsgTx { - - fundingOutpoint := h.aliceChannel.State().FundingOutpoint - - return &wire.MsgTx{ - TxIn: []*wire.TxIn{{ - PreviousOutPoint: fundingOutpoint, - Sequence: wire.MaxTxInSequenceNum, - }}, - TxOut: []*wire.TxOut{{ - Value: outputValue, - // Unique script. - PkScript: []byte{byte(outputValue % 255)}, - }}, - } -} - -// createRemoteForceCloseTx creates a remote force close transaction. -// From Alice's perspective, this is Bob's local commitment transaction. -func (h *chainWatcherTestHarness) createRemoteForceCloseTx() *wire.MsgTx { - return h.bobChannel.State().LocalCommitment.CommitTx -} - -// createLocalForceCloseTx creates a local force close transaction. -// This is Alice's local commitment transaction. -func (h *chainWatcherTestHarness) createLocalForceCloseTx() *wire.MsgTx { - return h.aliceChannel.State().LocalCommitment.CommitTx -} - -// createBreachCloseTx creates a breach (revoked commitment) transaction. -// We advance the channel state, save the commitment, then advance again -// to revoke it. Returns the revoked commitment tx. -func (h *chainWatcherTestHarness) createBreachCloseTx() *wire.MsgTx { - h.t.Helper() - - // To create a revoked commitment, we need to advance the channel state - // at least once. We'll use the test utils helper to add an HTLC and - // force a state transition. - - // Get the current commitment before we advance (this will be revoked). - revokedCommit := h.bobChannel.State().LocalCommitment.CommitTx - - // Add a fake HTLC to advance state. - htlcAmount := lnwire.NewMSatFromSatoshis(10000) - paymentHash := [32]byte{4, 5, 6} - htlc := &lnwire.UpdateAddHTLC{ - ID: 0, - Amount: htlcAmount, - Expiry: uint32(h.currentHeight + 100), - PaymentHash: paymentHash, - } - - // Add HTLC to both channels. - if _, err := h.aliceChannel.AddHTLC(htlc, nil); err != nil { - h.t.Fatalf("unable to add HTLC to alice: %v", err) - } - if _, err := h.bobChannel.ReceiveHTLC(htlc); err != nil { - h.t.Fatalf("unable to add HTLC to bob: %v", err) - } - - // Force state transition using the helper. - err := lnwallet.ForceStateTransition(h.aliceChannel, h.bobChannel) - if err != nil { - h.t.Fatalf("unable to force state transition: %v", err) - } - - // Return the revoked commitment (Bob's previous local commitment). - return revokedCommit -} - -// sendSpend sends a spend notification for the given transaction. -func (h *chainWatcherTestHarness) sendSpend(tx *wire.MsgTx) { - h.t.Helper() - - txHash := tx.TxHash() - spend := &chainntnfs.SpendDetail{ - SpenderTxHash: &txHash, - SpendingTx: tx, - SpendingHeight: h.currentHeight, - } - - select { - case h.notifier.SpendChan <- spend: - case <-time.After(time.Second): - h.t.Fatalf("unable to send spend notification") - } -} - -// confirmTx sends a confirmation notification for the given transaction. -func (h *chainWatcherTestHarness) confirmTx(tx *wire.MsgTx, height int32) { - h.t.Helper() - - // Find the confirmation event for this transaction. - txHash := tx.TxHash() - var confEvent *mockConfirmationEvent - for _, event := range h.notifier.confEvents { - if event.txid == txHash && !event.cancelled { - confEvent = event - break - } - } - - if confEvent == nil { - h.t.Fatalf("no confirmation event registered for tx %v", txHash) - } - - // Send confirmation. - select { - case confEvent.confirmedChan <- &chainntnfs.TxConfirmation{ - Tx: tx, - BlockHeight: uint32(height), - }: - case <-time.After(time.Second): - h.t.Fatalf("unable to send confirmation") - } -} - -// triggerReorg sends a negative confirmation (reorg) notification for the -// given transaction with the specified reorg depth. -func (h *chainWatcherTestHarness) triggerReorg(tx *wire.MsgTx, - reorgDepth int32) { - - h.t.Helper() - - // Find the confirmation event for this transaction. - txHash := tx.TxHash() - var confEvent *mockConfirmationEvent - for _, event := range h.notifier.confEvents { - if event.txid == txHash && !event.cancelled { - confEvent = event - break - } - } - - if confEvent == nil { - // The chain watcher might not have registered for - // confirmations yet. - return - } - - // Send negative confirmation. - select { - case confEvent.negConfChan <- reorgDepth: - case <-time.After(time.Second): - h.t.Fatalf("unable to send negative confirmation") - } -} - -// mineBlocks advances the current block height. -func (h *chainWatcherTestHarness) mineBlocks(n int32) { - h.currentHeight += n -} - -// waitForCoopClose waits for a cooperative close event and returns it. -func (h *chainWatcherTestHarness) waitForCoopClose( - timeout time.Duration) *CooperativeCloseInfo { - - h.t.Helper() - - select { - case coopClose := <-h.chanEvents.CooperativeClosure: - return coopClose - case <-time.After(timeout): - h.t.Fatalf("didn't receive cooperative close event") - return nil - } -} - -// waitForConfRegistration waits for the chain watcher to register for -// confirmation notifications. -func (h *chainWatcherTestHarness) waitForConfRegistration() { - h.t.Helper() - - select { - case <-h.notifier.confRegistered: - // Registration complete. - case <-time.After(2 * time.Second): - // Not necessarily a failure - some tests don't register. - } -} - -// waitForSpendRegistration waits for the chain watcher to register for -// spend notifications. -func (h *chainWatcherTestHarness) waitForSpendRegistration() { - h.t.Helper() - - select { - case <-h.notifier.spendRegistered: - // Registration complete. - case <-time.After(2 * time.Second): - // Not necessarily a failure - some tests don't register. - } -} - -// assertCoopCloseTx asserts that the given cooperative close info matches -// the expected transaction. -func (h *chainWatcherTestHarness) assertCoopCloseTx( - closeInfo *CooperativeCloseInfo, expectedTx *wire.MsgTx) { - - h.t.Helper() - - expectedHash := expectedTx.TxHash() - if closeInfo.ClosingTXID != expectedHash { - h.t.Fatalf("wrong tx confirmed: expected %v, got %v", - expectedHash, closeInfo.ClosingTXID) - } -} - -// assertNoCoopClose asserts that no cooperative close event is received -// within the given timeout. -func (h *chainWatcherTestHarness) assertNoCoopClose(timeout time.Duration) { - h.t.Helper() - - select { - case <-h.chanEvents.CooperativeClosure: - h.t.Fatalf("unexpected cooperative close event") - case <-time.After(timeout): - // Expected timeout. - } -} - -// runCoopCloseFlow runs a complete cooperative close flow including spend, -// optional reorg, and confirmation. This helper coordinates the timing -// between the different events. -func (h *chainWatcherTestHarness) runCoopCloseFlow( - tx *wire.MsgTx, shouldReorg bool, reorgDepth int32, - altTx *wire.MsgTx) *CooperativeCloseInfo { - - h.t.Helper() - - // Send initial spend notification. The closeObserver's state machine - // will detect this and register for confirmations. - h.sendSpend(tx) - - // Wait for the chain watcher to register for confirmations. - h.waitForConfRegistration() - - if shouldReorg { - // Trigger reorg which resets the state machine. - h.triggerReorg(tx, reorgDepth) - - // If we have an alternative transaction, send it. - if altTx != nil { - // After reorg, the chain watcher should re-register for - // ANY spend of the funding output. - h.waitForSpendRegistration() - - // Send alternative spend. - h.sendSpend(altTx) - - // Wait for it to register for confirmations. - h.waitForConfRegistration() - - // Confirm alternative transaction to unblock. - h.mineBlocks(1) - h.confirmTx(altTx, h.currentHeight) - } - } else { - // Normal confirmation flow - confirm to unblock - // waitForCoopCloseConfirmation. - h.mineBlocks(1) - h.confirmTx(tx, h.currentHeight) - } - - // Wait for cooperative close event. - return h.waitForCoopClose(5 * time.Second) -} - -// runMultipleReorgFlow simulates multiple consecutive reorganizations with -// different transactions confirming after each reorg. -func (h *chainWatcherTestHarness) runMultipleReorgFlow(txs []*wire.MsgTx, - reorgDepths []int32) *CooperativeCloseInfo { - - h.t.Helper() - - if len(txs) < 2 { - h.t.Fatalf("need at least 2 transactions for reorg flow") - } - if len(reorgDepths) != len(txs)-1 { - h.t.Fatalf("reorg depths must be one less than transactions") - } - - // Send initial spend. - h.sendSpend(txs[0]) - - // Process each reorg. - for i, depth := range reorgDepths { - // Wait for confirmation registration. - h.waitForConfRegistration() - - // Trigger reorg for current transaction. - h.triggerReorg(txs[i], depth) - - // Wait for re-registration for spend. - h.waitForSpendRegistration() - - // Send next transaction. - h.sendSpend(txs[i+1]) - } - - // Wait for final confirmation registration. - h.waitForConfRegistration() - - // Confirm the final transaction. - finalTx := txs[len(txs)-1] - h.mineBlocks(1) - h.confirmTx(finalTx, h.currentHeight) - - // Wait for cooperative close event. - return h.waitForCoopClose(10 * time.Second) -} - -// waitForRemoteUnilateralClose waits for a remote unilateral close event. -func (h *chainWatcherTestHarness) waitForRemoteUnilateralClose( - timeout time.Duration) *RemoteUnilateralCloseInfo { - - h.t.Helper() - - select { - case remoteClose := <-h.chanEvents.RemoteUnilateralClosure: - return remoteClose - case <-time.After(timeout): - h.t.Fatalf("didn't receive remote unilateral close event") - return nil - } -} - -// waitForLocalUnilateralClose waits for a local unilateral close event. -func (h *chainWatcherTestHarness) waitForLocalUnilateralClose( - timeout time.Duration) *LocalUnilateralCloseInfo { - - h.t.Helper() - - select { - case localClose := <-h.chanEvents.LocalUnilateralClosure: - return localClose - case <-time.After(timeout): - h.t.Fatalf("didn't receive local unilateral close event") - return nil - } -} - -// waitForBreach waits for a breach (contract breach) event. -func (h *chainWatcherTestHarness) waitForBreach( - timeout time.Duration) *BreachCloseInfo { - - h.t.Helper() - - select { - case breach := <-h.chanEvents.ContractBreach: - return breach - case <-time.After(timeout): - h.t.Fatalf("didn't receive contract breach event") - return nil - } -} - -// assertRemoteUnilateralCloseTx asserts that the given remote unilateral close -// info matches the expected transaction. -func (h *chainWatcherTestHarness) assertRemoteUnilateralCloseTx( - closeInfo *RemoteUnilateralCloseInfo, expectedTx *wire.MsgTx) { - - h.t.Helper() - - expectedHash := expectedTx.TxHash() - actualHash := closeInfo.UnilateralCloseSummary.SpendDetail.SpenderTxHash - if *actualHash != expectedHash { - h.t.Fatalf("wrong tx confirmed: expected %v, got %v", - expectedHash, *actualHash) - } -} - -// assertLocalUnilateralCloseTx asserts that the given local unilateral close -// info matches the expected transaction. -func (h *chainWatcherTestHarness) assertLocalUnilateralCloseTx( - closeInfo *LocalUnilateralCloseInfo, expectedTx *wire.MsgTx) { - - h.t.Helper() - - expectedHash := expectedTx.TxHash() - actualHash := closeInfo.LocalForceCloseSummary.CloseTx.TxHash() - if actualHash != expectedHash { - h.t.Fatalf("wrong tx confirmed: expected %v, got %v", - expectedHash, actualHash) - } -} - -// assertBreachTx asserts that the given breach info matches the expected -// transaction. -func (h *chainWatcherTestHarness) assertBreachTx( - breachInfo *BreachCloseInfo, expectedTx *wire.MsgTx) { - - h.t.Helper() - - expectedHash := expectedTx.TxHash() - if breachInfo.CommitHash != expectedHash { - h.t.Fatalf("wrong tx confirmed: expected %v, got %v", - expectedHash, breachInfo.CommitHash) - } -} diff --git a/docs/release-notes/release-notes-0.20.1.md b/docs/release-notes/release-notes-0.20.1.md index 36fa3da62..0d39f503e 100644 --- a/docs/release-notes/release-notes-0.20.1.md +++ b/docs/release-notes/release-notes-0.20.1.md @@ -93,13 +93,6 @@ ensures dependencies are properly freed and logs the panic trace for debugging. -* [Improved confirmation scaling for cooperative - closes](https://github.com/lightningnetwork/lnd/pull/10331) to provide better - reorg protection. Previously, cooperative closes required a minimum of 3 - confirmations. Now, small channels only require 1 confirmation, while larger - channels scale proportionally using the standard 0.16 BTC threshold (matching - funding confirmation scaling). - ## RPC Updates * The `EstimateRouteFee` RPC now implements an [LSP detection @@ -114,15 +107,6 @@ ## Breaking Changes -* [Increased MinCLTVDelta from 18 to - 24](https://github.com/lightningnetwork/lnd/pull/TODO) to provide a larger - safety margin above the `DefaultFinalCltvRejectDelta` (19 blocks). This - affects users who create invoices with custom `cltv_expiry_delta` values - between 18-23, which will now require a minimum of 24. The default value of - 80 blocks for invoice creation remains unchanged, so most users will not be - affected. Existing invoices created before the upgrade will continue to work - normally. - ## Performance Improvements * [Added new Postgres configuration @@ -161,5 +145,4 @@ * Abdulkbk * bitromortac -* Olaoluwa Osuntokun * Ziggie diff --git a/itest/list_on_test.go b/itest/list_on_test.go index ec608c629..92c6547b3 100644 --- a/itest/list_on_test.go +++ b/itest/list_on_test.go @@ -727,10 +727,6 @@ var allTestCases = []*lntest.TestCase{ Name: "rbf coop close disconnect", TestFunc: testRBFCoopCloseDisconnect, }, - { - Name: "coop close rbf with reorg", - TestFunc: testCoopCloseRBFWithReorg, - }, { Name: "bump fee low budget", TestFunc: testBumpFeeLowBudget, diff --git a/itest/lnd_channel_policy_test.go b/itest/lnd_channel_policy_test.go index 7def317ba..7a333f073 100644 --- a/itest/lnd_channel_policy_test.go +++ b/itest/lnd_channel_policy_test.go @@ -295,7 +295,7 @@ func testUpdateChannelPolicy(ht *lntest.HarnessTest) { // propagated. baseFee = int64(800) feeRate = int64(123) - timeLockDelta = uint32(24) + timeLockDelta = uint32(22) maxHtlc *= 2 inboundBaseFee := int32(-400) inboundFeeRatePpm := int32(-60) diff --git a/itest/lnd_coop_close_rbf_test.go b/itest/lnd_coop_close_rbf_test.go index 13e10c9f7..5f8b15d40 100644 --- a/itest/lnd_coop_close_rbf_test.go +++ b/itest/lnd_coop_close_rbf_test.go @@ -1,13 +1,8 @@ package itest import ( - "fmt" - "github.com/btcsuite/btcd/btcutil" - "github.com/btcsuite/btcd/chaincfg/chainhash" - "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lntest" - "github.com/lightningnetwork/lnd/lntest/wait" "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/stretchr/testify/require" ) @@ -158,172 +153,3 @@ func testRBFCoopCloseDisconnect(ht *lntest.HarnessTest) { // Disconnect Bob from Alice. ht.DisconnectNodes(alice, bob) } - -// testCoopCloseRBFWithReorg tests that when a cooperative close transaction -// is reorganized out during confirmation waiting, the system properly handles -// RBF replacements and re-registration for any spend of the funding output. -func testCoopCloseRBFWithReorg(ht *lntest.HarnessTest) { - // Skip this test for neutrino backend as we can't trigger reorgs. - if ht.IsNeutrinoBackend() { - ht.Skipf("skipping reorg test for neutrino backend") - } - - // Force cooperative close to require 3 confirmations for predictable - // testing. - const requiredConfs = 3 - rbfCoopFlags := []string{ - "--protocol.rbf-coop-close", - "--dev.force-channel-close-confs=3", - } - - // Set the fee estimate to 1sat/vbyte to ensure our RBF attempts work. - ht.SetFeeEstimate(250) - ht.SetFeeEstimateWithConf(250, 6) - - // Create two nodes with enough coins for a 50/50 channel. - cfgs := [][]string{rbfCoopFlags, rbfCoopFlags} - params := lntest.OpenChannelParams{ - Amt: btcutil.Amount(10_000_000), - PushAmt: btcutil.Amount(5_000_000), - } - chanPoints, nodes := ht.CreateSimpleNetwork(cfgs, params) - alice, bob := nodes[0], nodes[1] - chanPoint := chanPoints[0] - - // Initiate cooperative close with initial fee rate of 5 sat/vb. - initialFeeRate := chainfee.SatPerVByte(5) - _, aliceCloseUpdate := ht.CloseChannelAssertPending( - alice, chanPoint, false, - lntest.WithCoopCloseFeeRate(initialFeeRate), - lntest.WithLocalTxNotify(), - ) - - // Verify the initial close transaction is at the expected fee rate. - alicePendingUpdate := aliceCloseUpdate.GetClosePending() - require.NotNil(ht, aliceCloseUpdate) - require.Equal( - ht, int64(initialFeeRate), alicePendingUpdate.FeePerVbyte, - ) - - // Capture the initial close transaction from the mempool. - initialCloseTxid, err := chainhash.NewHash(alicePendingUpdate.Txid) - require.NoError(ht, err) - initialCloseTx := ht.AssertTxInMempool(*initialCloseTxid) - - // Create first RBF replacement before any mining. - firstRbfFeeRate := chainfee.SatPerVByte(10) - _, firstRbfUpdate := ht.CloseChannelAssertPending( - bob, chanPoint, false, - lntest.WithCoopCloseFeeRate(firstRbfFeeRate), - lntest.WithLocalTxNotify(), - ) - - // Capture the first RBF transaction. - closePending := firstRbfUpdate.GetClosePending() - firstRbfTxid, err := chainhash.NewHash(closePending.Txid) - require.NoError(ht, err) - firstRbfTx := ht.AssertTxInMempool(*firstRbfTxid) - - _, bestHeight := ht.GetBestBlock() - ht.Logf("Current block height: %d", bestHeight) - - // Mine n-1 blocks (2 blocks when requiring 3 confirmations) with the - // first RBF transaction. This is just shy of full confirmation. - block1 := ht.Miner().MineBlockWithTxes( - []*btcutil.Tx{btcutil.NewTx(firstRbfTx)}, - ) - - ht.Logf("Mined block %d with first RBF tx", bestHeight+1) - - block2 := ht.MineEmptyBlocks(1)[0] - - ht.Logf("Mined block %d", bestHeight+2) - - ht.Logf("Re-orging two blocks to remove first RBF tx") - - // Trigger a reorganization that removes the last 2 blocks. This is safe - // because we haven't reached full confirmation yet. - bestBlockHash := block2.Header.BlockHash() - require.NoError( - ht, ht.Miner().Client.InvalidateBlock(&bestBlockHash), - ) - bestBlockHash = block1.Header.BlockHash() - require.NoError( - ht, ht.Miner().Client.InvalidateBlock(&bestBlockHash), - ) - - _, bestHeight = ht.GetBestBlock() - ht.Logf("Re-orged to block height: %d", bestHeight) - - ht.Log("Mining blocks to surpass previous chain") - - // Mine 2 empty blocks to trigger the reorg on the nodes. - ht.MineEmptyBlocks(2) - - _, bestHeight = ht.GetBestBlock() - ht.Logf("Mined blocks to reach height: %d", bestHeight) - - // Now, instead of mining the second RBF, mine the INITIAL transaction - // to test that the system can handle any valid spend of the funding - // output. - block := ht.Miner().MineBlockWithTxes( - []*btcutil.Tx{btcutil.NewTx(initialCloseTx)}, - ) - ht.AssertTxInBlock(block, *initialCloseTxid) - - // Mine additional blocks to reach the required confirmations (3 total). - ht.MineEmptyBlocks(requiredConfs - 1) - - // Both parties should see that the channel is now fully closed on chain - // with the expected closing txid. - expectedClosingTxid := initialCloseTxid.String() - err = wait.NoError(func() error { - req := &lnrpc.ClosedChannelsRequest{} - aliceClosedChans := alice.RPC.ClosedChannels(req) - bobClosedChans := bob.RPC.ClosedChannels(req) - if len(aliceClosedChans.Channels) != 1 { - return fmt.Errorf("alice: expected 1 closed "+ - "chan, got %d", len(aliceClosedChans.Channels)) - } - if len(bobClosedChans.Channels) != 1 { - return fmt.Errorf("bob: expected 1 closed chan, got %d", - len(bobClosedChans.Channels)) - } - - // Verify both Alice and Bob have the expected closing txid. - aliceClosedChan := aliceClosedChans.Channels[0] - if aliceClosedChan.ClosingTxHash != expectedClosingTxid { - return fmt.Errorf("alice: expected closing txid %s, "+ - "got %s", - expectedClosingTxid, - aliceClosedChan.ClosingTxHash) - } - if aliceClosedChan.CloseType != - lnrpc.ChannelCloseSummary_COOPERATIVE_CLOSE { - - return fmt.Errorf("alice: expected cooperative "+ - "close, got %v", - aliceClosedChan.CloseType) - } - - bobClosedChan := bobClosedChans.Channels[0] - if bobClosedChan.ClosingTxHash != expectedClosingTxid { - return fmt.Errorf("bob: expected closing txid %s, "+ - "got %s", - expectedClosingTxid, - bobClosedChan.ClosingTxHash) - } - if bobClosedChan.CloseType != - lnrpc.ChannelCloseSummary_COOPERATIVE_CLOSE { - - return fmt.Errorf("bob: expected cooperative "+ - "close, got %v", - bobClosedChan.CloseType) - } - - return nil - }, defaultTimeout) - require.NoError(ht, err) - - ht.Logf("Successfully verified closing txid: %s", expectedClosingTxid) -} diff --git a/itest/lnd_funding_test.go b/itest/lnd_funding_test.go index 2c1daf53d..b6734e032 100644 --- a/itest/lnd_funding_test.go +++ b/itest/lnd_funding_test.go @@ -1272,17 +1272,8 @@ func testChannelFundingWithUnstableUtxos(ht *lntest.HarnessTest) { // Make sure Carol sees her to_remote output from the force close tx. ht.AssertNumPendingSweeps(carol, 1) - // Wait for Carol's sweep transaction to appear in the mempool. Due to - // async confirmation notifications, there's a race between when the - // sweep is registered and when the sweeper processes the next block. - // The sweeper uses immediate=false, so it broadcasts on the next block - // after registration. Mine an empty block to trigger the broadcast. - ht.MineEmptyBlocks(1) - - // Now the sweep should be in the mempool. - ht.AssertNumTxsInMempool(1) - - // Now we should see the unconfirmed UTXO from the sweep. + // We need to wait for carol initiating the sweep of the to_remote + // output of chanPoint2. utxo := ht.AssertNumUTXOsUnconfirmed(carol, 1)[0] // We now try to open channel using the unconfirmed utxo. @@ -1338,11 +1329,6 @@ func testChannelFundingWithUnstableUtxos(ht *lntest.HarnessTest) { // Make sure Carol sees her to_remote output from the force close tx. ht.AssertNumPendingSweeps(carol, 1) - // Mine an empty block to trigger the sweep broadcast (same fix as - // above). - ht.MineEmptyBlocks(1) - ht.AssertNumTxsInMempool(1) - // Wait for the to_remote sweep tx to show up in carol's wallet. ht.AssertNumUTXOsUnconfirmed(carol, 1) diff --git a/itest/lnd_htlc_timeout_resolver_test.go b/itest/lnd_htlc_timeout_resolver_test.go index 271008625..25aa0afcc 100644 --- a/itest/lnd_htlc_timeout_resolver_test.go +++ b/itest/lnd_htlc_timeout_resolver_test.go @@ -14,8 +14,8 @@ import ( ) const ( - finalCltvDelta = routing.MinCLTVDelta // 24. - thawHeightDelta = finalCltvDelta * 2 // 48. + finalCltvDelta = routing.MinCLTVDelta // 18. + thawHeightDelta = finalCltvDelta * 2 // 36. ) // makeRouteHints creates a route hints that will allow Carol to be reached diff --git a/itest/lnd_route_blinding_test.go b/itest/lnd_route_blinding_test.go index b8339fb04..af2612d24 100644 --- a/itest/lnd_route_blinding_test.go +++ b/itest/lnd_route_blinding_test.go @@ -352,7 +352,7 @@ func (b *blindedForwardTest) setupNetwork(ctx context.Context, withInterceptor bool) { carolArgs := []string{ - "--bitcoin.timelockdelta=24", + "--bitcoin.timelockdelta=18", fmt.Sprintf("--bitcoin.defaultremotedelay=%v", toLocalCSV), } if withInterceptor { @@ -360,7 +360,7 @@ func (b *blindedForwardTest) setupNetwork(ctx context.Context, } daveArgs := []string{ - "--bitcoin.timelockdelta=24", + "--bitcoin.timelockdelta=18", fmt.Sprintf("--bitcoin.defaultremotedelay=%v", toLocalCSV), } cfgs := [][]string{nil, nil, carolArgs, daveArgs} diff --git a/itest/lnd_sweep_test.go b/itest/lnd_sweep_test.go index 3874786bf..c5bcd3b15 100644 --- a/itest/lnd_sweep_test.go +++ b/itest/lnd_sweep_test.go @@ -879,7 +879,7 @@ func testSweepHTLCs(ht *lntest.HarnessTest) { // Before we mine empty blocks to check the RBF behavior, we need to be // aware that Bob's incoming HTLC will expire before his outgoing HTLC // deadline is reached. This happens because the incoming HTLC is sent - // onchain at CLTVDelta-BroadcastDelta=24-16=8, which means after 8 + // onchain at CLTVDelta-BroadcastDelta=18-10=8, which means after 8 // blocks are mined, we expect Bob force closes the channel Alice->Bob. blocksTillIncomingSweep := cltvDelta - lncfg.DefaultIncomingBroadcastDelta diff --git a/lncfg/config.go b/lncfg/config.go index c0ab51f26..178ef203b 100644 --- a/lncfg/config.go +++ b/lncfg/config.go @@ -20,16 +20,11 @@ const ( // DefaultIncomingBroadcastDelta defines the number of blocks before the // expiry of an incoming htlc at which we force close the channel. We // only go to chain if we also have the preimage to actually pull in the - // htlc. BOLT #2 suggests 7 blocks. We use more for extra safety. - // - // The value accounts for: - // - Up to 6 blocks waiting for close tx confirmation (reorg safety) - // - Time to broadcast and confirm our sweep/2nd level success tx - // - // Within this window we need to get our sweep confirmed, because after - // that the remote party is also able to claim the htlc using the - // timeout path. - DefaultIncomingBroadcastDelta = 16 + // htlc. BOLT #2 suggests 7 blocks. We use a few more for extra safety. + // Within this window we need to get our sweep or 2nd level success tx + // confirmed, because after that the remote party is also able to claim + // the htlc using the timeout path. + DefaultIncomingBroadcastDelta = 10 // DefaultFinalCltvRejectDelta defines the number of blocks before the // expiry of an incoming exit hop htlc at which we cancel it back diff --git a/lncfg/dev.go b/lncfg/dev.go index 8e0c9dda4..f048d69b7 100644 --- a/lncfg/dev.go +++ b/lncfg/dev.go @@ -5,7 +5,6 @@ package lncfg import ( "time" - "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/lnwallet/chanfunding" ) @@ -59,9 +58,3 @@ func (d *DevConfig) GetMaxWaitNumBlocksFundingConf() uint32 { func (d *DevConfig) GetUnsafeConnect() bool { return false } - -// ChannelCloseConfs returns the config value for channel close confirmations -// override, which is always None for production build. -func (d *DevConfig) ChannelCloseConfs() fn.Option[uint32] { - return fn.None[uint32]() -} diff --git a/lncfg/dev_integration.go b/lncfg/dev_integration.go index b299fb4fc..8ac85f5d9 100644 --- a/lncfg/dev_integration.go +++ b/lncfg/dev_integration.go @@ -5,7 +5,6 @@ package lncfg import ( "time" - "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/lnwallet/chanfunding" ) @@ -28,7 +27,6 @@ type DevConfig struct { UnsafeDisconnect bool `long:"unsafedisconnect" description:"Allows the rpcserver to intentionally disconnect from peers with open channels."` MaxWaitNumBlocksFundingConf uint32 `long:"maxwaitnumblocksfundingconf" description:"Maximum blocks to wait for funding confirmation before discarding non-initiated channels."` UnsafeConnect bool `long:"unsafeconnect" description:"Allow the rpcserver to connect to a peer even if there's already a connection."` - ForceChannelCloseConfs uint32 `long:"force-channel-close-confs" description:"Force a specific number of confirmations for channel closes (dev/test only)"` } // ChannelReadyWait returns the config value `ProcessChannelReadyWait`. @@ -73,13 +71,3 @@ func (d *DevConfig) GetMaxWaitNumBlocksFundingConf() uint32 { func (d *DevConfig) GetUnsafeConnect() bool { return d.UnsafeConnect } - -// ChannelCloseConfs returns the forced confirmation count if set, or None if -// the default behavior should be used. -func (d *DevConfig) ChannelCloseConfs() fn.Option[uint32] { - if d.ForceChannelCloseConfs == 0 { - return fn.None[uint32]() - } - - return fn.Some(d.ForceChannelCloseConfs) -} diff --git a/lntest/harness.go b/lntest/harness.go index b56ac2a95..21c32a531 100644 --- a/lntest/harness.go +++ b/lntest/harness.go @@ -54,15 +54,15 @@ const ( // mining blocks. maxBlocksAllowed = 100 - finalCltvDelta = routing.MinCLTVDelta // 24. - thawHeightDelta = finalCltvDelta * 2 // 48. + finalCltvDelta = routing.MinCLTVDelta // 18. + thawHeightDelta = finalCltvDelta * 2 // 36. ) var ( // MaxBlocksMinedPerTest is the maximum number of blocks that we allow // a test to mine. This is an exported global variable so it can be // overwritten by other projects that don't have the same constraints. - MaxBlocksMinedPerTest = 70 + MaxBlocksMinedPerTest = 50 ) // TestCase defines a test case that's been used in the integration test. @@ -409,13 +409,13 @@ func (h *HarnessTest) checkAndLimitBlocksMined(startHeight int32) { desc += "1. break test into smaller individual tests, especially if " + "this is a table-drive test.\n" + "2. use smaller CSV via `--bitcoin.defaultremotedelay=1.`\n" + - "3. use smaller CLTV via `--bitcoin.timelockdelta=24.`\n" + + "3. use smaller CLTV via `--bitcoin.timelockdelta=18.`\n" + "4. remove unnecessary CloseChannel when test ends.\n" + "5. use `CreateSimpleNetwork` for efficient channel creation.\n" h.Log(desc) // We enforce that the test should not mine more than - // MaxBlocksMinedPerTest (70 by default) blocks, which is more than + // MaxBlocksMinedPerTest (50 by default) blocks, which is more than // enough to test a multi hop force close scenario. require.LessOrEqualf( h, int(blocksMined), MaxBlocksMinedPerTest, diff --git a/lntest/harness_assertion.go b/lntest/harness_assertion.go index 011c03d8d..544576bef 100644 --- a/lntest/harness_assertion.go +++ b/lntest/harness_assertion.go @@ -18,7 +18,6 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" - "github.com/davecgh/go-spew/spew" "github.com/lightningnetwork/lnd/channeldb" "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnrpc/invoicesrpc" @@ -553,10 +552,8 @@ func (h HarnessTest) WaitForChannelCloseEvent( require.NoError(h, err) resp, ok := event.Update.(*lnrpc.CloseStatusUpdate_ChanClose) - require.Truef( - h, ok, "expected channel close update, instead got %T: %v", - event.Update, spew.Sdump(event.Update), - ) + require.Truef(h, ok, "expected channel close update, instead got %v", + event.Update) txid, err := chainhash.NewHash(resp.ChanClose.ClosingTxid) require.NoErrorf(h, err, "wrong format found in closing txid: %v", diff --git a/lntest/mock/chainnotifier.go b/lntest/mock/chainnotifier.go index 9a9e125bd..ddce8defa 100644 --- a/lntest/mock/chainnotifier.go +++ b/lntest/mock/chainnotifier.go @@ -1,9 +1,6 @@ package mock import ( - "testing" - "time" - "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/lightningnetwork/lnd/chainntnfs" @@ -11,10 +8,9 @@ import ( // ChainNotifier is a mock implementation of the ChainNotifier interface. type ChainNotifier struct { - SpendChan chan *chainntnfs.SpendDetail - EpochChan chan *chainntnfs.BlockEpoch - ConfChan chan *chainntnfs.TxConfirmation - ConfRegistered chan struct{} + SpendChan chan *chainntnfs.SpendDetail + EpochChan chan *chainntnfs.BlockEpoch + ConfChan chan *chainntnfs.TxConfirmation } // RegisterConfirmationsNtfn returns a ConfirmationEvent that contains a channel @@ -23,14 +19,6 @@ func (c *ChainNotifier) RegisterConfirmationsNtfn(txid *chainhash.Hash, pkScript []byte, numConfs, heightHint uint32, opts ...chainntnfs.NotifierOption) (*chainntnfs.ConfirmationEvent, error) { - // Signal that a confirmation registration occurred. - if c.ConfRegistered != nil { - select { - case c.ConfRegistered <- struct{}{}: - default: - } - } - return &chainntnfs.ConfirmationEvent{ Confirmed: c.ConfChan, Cancel: func() {}, @@ -73,25 +61,3 @@ func (c *ChainNotifier) Started() bool { func (c *ChainNotifier) Stop() error { return nil } - -// WaitForConfRegistrationAndSend waits for a confirmation registration to -// occur and then sends a confirmation notification. This is a helper function -// for tests that need to ensure the chain watcher has registered for -// confirmations before sending the confirmation. -func (c *ChainNotifier) WaitForConfRegistrationAndSend(t *testing.T) { - t.Helper() - - // Wait for the chain watcher to register for confirmations. - select { - case <-c.ConfRegistered: - case <-time.After(time.Second * 2): - t.Fatalf("timeout waiting for conf registration") - } - - // Send the confirmation to satisfy the confirmation requirement. - select { - case c.ConfChan <- &chainntnfs.TxConfirmation{}: - case <-time.After(time.Second * 1): - t.Fatalf("unable to send confirmation") - } -} diff --git a/lnwallet/confscale.go b/lnwallet/confscale.go deleted file mode 100644 index 6e2b010e6..000000000 --- a/lnwallet/confscale.go +++ /dev/null @@ -1,58 +0,0 @@ -package lnwallet - -import ( - "github.com/btcsuite/btcd/btcutil" - "github.com/lightningnetwork/lnd/lnwire" -) - -const ( - // minRequiredConfs is the minimum number of confirmations we'll - // require for channel operations. - minRequiredConfs = 1 - - // maxRequiredConfs is the maximum number of confirmations we'll - // require for channel operations. - maxRequiredConfs = 6 - - // maxChannelSize is the maximum expected channel size in satoshis. - // This matches MaxBtcFundingAmount (0.16777215 BTC). - maxChannelSize = 16777215 -) - -// ScaleNumConfs returns a linearly scaled number of confirmations based on the -// provided channel amount and push amount (for funding transactions). The push -// amount represents additional risk when receiving funds. -func ScaleNumConfs(chanAmt btcutil.Amount, pushAmt lnwire.MilliSatoshi) uint16 { - // For wumbo channels, always require maximum confirmations. - if chanAmt > maxChannelSize { - return maxRequiredConfs - } - - // Calculate total stake: channel amount + push amount. The push amount - // represents value at risk for the receiver. - maxChannelSizeMsat := lnwire.NewMSatFromSatoshis(maxChannelSize) - stake := lnwire.NewMSatFromSatoshis(chanAmt) + pushAmt - - // Scale confirmations linearly based on stake. - conf := uint64(maxRequiredConfs) * uint64(stake) / - uint64(maxChannelSizeMsat) - - // Bound the result between minRequiredConfs and maxRequiredConfs. - if conf < minRequiredConfs { - conf = minRequiredConfs - } - if conf > maxRequiredConfs { - conf = maxRequiredConfs - } - - return uint16(conf) -} - -// FundingConfsForAmounts returns the number of confirmations to wait for a -// funding transaction, taking into account both the channel amount and any -// pushed amount (which represents additional risk). -func FundingConfsForAmounts(chanAmt btcutil.Amount, - pushAmt lnwire.MilliSatoshi) uint16 { - - return ScaleNumConfs(chanAmt, pushAmt) -} diff --git a/lnwallet/confscale_integration.go b/lnwallet/confscale_integration.go deleted file mode 100644 index 4e78b968a..000000000 --- a/lnwallet/confscale_integration.go +++ /dev/null @@ -1,13 +0,0 @@ -//go:build integration -// +build integration - -package lnwallet - -import "github.com/btcsuite/btcd/btcutil" - -// CloseConfsForCapacity returns the number of confirmations to wait -// before signaling a cooperative close. Under integration tests, we -// always return 1 to keep tests fast and deterministic. -func CloseConfsForCapacity(capacity btcutil.Amount) uint32 { //nolint:revive - return 1 -} diff --git a/lnwallet/confscale_prod.go b/lnwallet/confscale_prod.go deleted file mode 100644 index 898810739..000000000 --- a/lnwallet/confscale_prod.go +++ /dev/null @@ -1,25 +0,0 @@ -//go:build !integration -// +build !integration - -package lnwallet - -import "github.com/btcsuite/btcd/btcutil" - -// CloseConfsForCapacity returns the number of confirmations to wait before -// signaling a channel close, scaled by channel capacity. This is used for both -// cooperative and force closes. We enforce a minimum of 3 confirmations to -// provide better reorg protection, even for small channels. -func CloseConfsForCapacity(capacity btcutil.Amount) uint32 { - // For cooperative closes, we don't have a push amount to consider, - // so we pass 0 for the pushAmt parameter. - scaledConfs := uint32(ScaleNumConfs(capacity, 0)) - - // Enforce a minimum of 3 confirmations for reorg safety. - // This protects against shallow reorgs which are more common. - const minCloseConfs = 3 - if scaledConfs < minCloseConfs { - return minCloseConfs - } - - return scaledConfs -} diff --git a/lnwallet/confscale_test.go b/lnwallet/confscale_test.go deleted file mode 100644 index 53165fc23..000000000 --- a/lnwallet/confscale_test.go +++ /dev/null @@ -1,340 +0,0 @@ -package lnwallet - -import ( - "testing" - - "github.com/btcsuite/btcd/btcutil" - "github.com/lightningnetwork/lnd/lnwire" - "github.com/stretchr/testify/require" - "pgregory.net/rapid" -) - -// TestScaleNumConfsProperties tests various properties that ScaleNumConfs -// should satisfy using property-based testing. -func TestScaleNumConfsProperties(t *testing.T) { - t.Parallel() - - // The result should always be bounded between the minimum and maximum - // number of confirmations regardless of input values. - t.Run("bounded_result", func(t *testing.T) { - rapid.Check(t, func(t *rapid.T) { - // Generate random channel amount and push amount. - chanAmt := rapid.Uint64Range( - 0, maxChannelSize*10, - ).Draw(t, "chanAmt") - pushAmtSats := rapid.Uint64Range( - 0, chanAmt, - ).Draw(t, "pushAmtSats") - pushAmt := lnwire.NewMSatFromSatoshis( - btcutil.Amount(pushAmtSats), - ) - - result := ScaleNumConfs( - btcutil.Amount(chanAmt), pushAmt, - ) - - // Check bounds - require.GreaterOrEqual( - t, result, uint16(minRequiredConfs), - "result should be >= minRequiredConfs", - ) - require.LessOrEqual( - t, result, uint16(maxRequiredConfs), - "result should be <= maxRequiredConfs", - ) - }) - }) - - // Larger channel amounts and push amounts should require equal or more - // confirmations, ensuring the function is monotonically increasing. - t.Run("monotonicity", func(t *testing.T) { - rapid.Check(t, func(t *rapid.T) { - // Generate two channel amounts where amt1 <= amt2. - amt1 := rapid.Uint64Range( - 0, maxChannelSize, - ).Draw(t, "amt1") - amt2 := rapid.Uint64Range( - amt1, maxChannelSize, - ).Draw(t, "amt2") - - // Generate push amounts proportional to channel size. - pushAmt1Sats := rapid.Uint64Range( - 0, amt1, - ).Draw(t, "pushAmt1") - pushAmt2Sats := rapid.Uint64Range( - pushAmt1Sats, amt2, - ).Draw(t, "pushAmt2") - - pushAmt1 := lnwire.NewMSatFromSatoshis( - btcutil.Amount(pushAmt1Sats), - ) - pushAmt2 := lnwire.NewMSatFromSatoshis( - btcutil.Amount(pushAmt2Sats), - ) - - confs1 := ScaleNumConfs(btcutil.Amount(amt1), pushAmt1) - confs2 := ScaleNumConfs(btcutil.Amount(amt2), pushAmt2) - - // Larger or equal stake should require equal or more - // confirmations. - require.GreaterOrEqual( - t, confs2, confs1, - "larger amount should require equal or "+ - "more confirmations", - ) - }) - }) - - // Wumbo channels (those exceeding the max standard channel size) should - // always require the maximum number of confirmations for safety. - t.Run("wumbo_max_confs", func(t *testing.T) { - rapid.Check(t, func(t *rapid.T) { - // Generate wumbo channel amount (above maxChannelSize). - wumboAmt := rapid.Uint64Range( - maxChannelSize+1, maxChannelSize*100, - ).Draw(t, "wumboAmt") - pushAmtSats := rapid.Uint64Range( - 0, wumboAmt, - ).Draw(t, "pushAmtSats") - pushAmt := lnwire.NewMSatFromSatoshis( - btcutil.Amount(pushAmtSats), - ) - - result := ScaleNumConfs( - btcutil.Amount(wumboAmt), pushAmt, - ) - - require.Equal( - t, uint16(maxRequiredConfs), result, - "wumbo channels should always get "+ - "max confirmations", - ) - }) - }) - - // Zero channel amounts should always result in the minimum number of - // confirmations since there's no value at risk. - t.Run("zero_gets_min", func(t *testing.T) { - result := ScaleNumConfs(0, 0) - require.Equal( - t, uint16(minRequiredConfs), result, - "zero amount should get minimum confirmations", - ) - }) - - // The function should be deterministic, always returning the same - // output for the same input values. - t.Run("determinism", func(t *testing.T) { - rapid.Check(t, func(t *rapid.T) { - chanAmt := rapid.Uint64Range( - 0, maxChannelSize*2, - ).Draw(t, "chanAmt") - pushAmtSats := rapid.Uint64Range( - 0, chanAmt, - ).Draw(t, "pushAmtSats") - pushAmt := lnwire.NewMSatFromSatoshis( - btcutil.Amount(pushAmtSats), - ) - - // Call multiple times with same inputs. - result1 := ScaleNumConfs( - btcutil.Amount(chanAmt), pushAmt, - ) - result2 := ScaleNumConfs( - btcutil.Amount(chanAmt), pushAmt, - ) - result3 := ScaleNumConfs( - btcutil.Amount(chanAmt), pushAmt, - ) - - require.Equal( - t, result1, result2, - "function should be deterministic", - ) - require.Equal( - t, result2, result3, - "function should be deterministic", - ) - }) - }) - - // Adding a push amount to a channel should require equal or more - // confirmations compared to the same channel without a push amount. - t.Run("push_amount_effect", func(t *testing.T) { - rapid.Check(t, func(t *rapid.T) { - // Fix channel amount, vary push amount - chanAmt := rapid.Uint64Range( - 1, maxChannelSize, - ).Draw(t, "chanAmt") - pushAmt1Sats := rapid.Uint64Range( - 0, chanAmt/2, - ).Draw(t, "pushAmt1") - pushAmt2Sats := rapid.Uint64Range( - pushAmt1Sats, chanAmt, - ).Draw(t, "pushAmt2") - - pushAmt1 := lnwire.NewMSatFromSatoshis( - btcutil.Amount(pushAmt1Sats), - ) - pushAmt2 := lnwire.NewMSatFromSatoshis( - btcutil.Amount(pushAmt2Sats), - ) - - confs1 := ScaleNumConfs( - btcutil.Amount(chanAmt), pushAmt1, - ) - confs2 := ScaleNumConfs( - btcutil.Amount(chanAmt), pushAmt2, - ) - - // More push amount should require equal or more - // confirmations. - require.GreaterOrEqual( - t, confs2, confs1, - "larger push amount should "+ - "require equal or more confirmations", - ) - }) - }) -} - -// TestScaleNumConfsKnownValues tests ScaleNumConfs with specific known values -// to ensure the scaling formula works as expected. -func TestScaleNumConfsKnownValues(t *testing.T) { - t.Parallel() - - testCases := []struct { - name string - chanAmt btcutil.Amount - pushAmt lnwire.MilliSatoshi - expected uint16 - }{ - { - name: "zero amounts", - chanAmt: 0, - pushAmt: 0, - expected: minRequiredConfs, - }, - { - name: "tiny channel", - chanAmt: 1000, - pushAmt: 0, - expected: minRequiredConfs, - }, - { - name: "small channel no push", - chanAmt: 100_000, - pushAmt: 0, - expected: minRequiredConfs, - }, - { - name: "half max channel no push", - chanAmt: maxChannelSize / 2, - pushAmt: 0, - expected: 2, - }, - { - name: "max channel no push", - chanAmt: maxChannelSize, - pushAmt: 0, - expected: maxRequiredConfs, - }, - { - name: "wumbo channel", - chanAmt: maxChannelSize * 2, - pushAmt: 0, - expected: maxRequiredConfs, - }, - { - name: "small channel with push", - chanAmt: 100_000, - pushAmt: lnwire.NewMSatFromSatoshis(50_000), - expected: minRequiredConfs, - }, - { - name: "medium channel with significant push", - chanAmt: maxChannelSize / 4, - pushAmt: lnwire.NewMSatFromSatoshis( - maxChannelSize / 4, - ), - expected: 2, - }, - } - - for _, tc := range testCases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - result := ScaleNumConfs(tc.chanAmt, tc.pushAmt) - - require.Equal( - t, tc.expected, result, - "chanAmt=%d, pushAmt=%d", tc.chanAmt, - tc.pushAmt, - ) - }) - } -} - -// TestFundingConfsForAmounts verifies that FundingConfsForAmounts is a simple -// wrapper around ScaleNumConfs. -func TestFundingConfsForAmounts(t *testing.T) { - t.Parallel() - - rapid.Check(t, func(t *rapid.T) { - chanAmt := rapid.Uint64Range( - 0, maxChannelSize*2, - ).Draw(t, "chanAmt") - pushAmtSats := rapid.Uint64Range( - 0, chanAmt, - ).Draw(t, "pushAmtSats") - pushAmt := lnwire.NewMSatFromSatoshis( - btcutil.Amount(pushAmtSats), - ) - - // Both functions should return the same result. - scaleResult := ScaleNumConfs(btcutil.Amount(chanAmt), pushAmt) - fundingResult := FundingConfsForAmounts( - btcutil.Amount(chanAmt), pushAmt, - ) - - require.Equal( - t, scaleResult, fundingResult, - "FundingConfsForAmounts should return "+ - "same result as ScaleNumConfs", - ) - }) -} - -// TestCloseConfsForCapacity verifies that CloseConfsForCapacity correctly -// wraps ScaleNumConfs with zero push amount and enforces a minimum of 3 -// confirmations for reorg safety. -func TestCloseConfsForCapacity(t *testing.T) { - t.Parallel() - - rapid.Check(t, func(t *rapid.T) { - capacity := rapid.Uint64Range( - 0, maxChannelSize*2, - ).Draw(t, "capacity") - - // CloseConfsForCapacity should be equivalent to ScaleNumConfs - // with 0 push, but with a minimum of 3 confirmations enforced - // for reorg safety. - closeConfs := CloseConfsForCapacity(btcutil.Amount(capacity)) - scaleConfs := ScaleNumConfs(btcutil.Amount(capacity), 0) - - // The result should be at least the scaled value, but with a - // minimum of 3 confirmations. - const minCloseConfs = 3 - expectedConfs := uint32(scaleConfs) - if expectedConfs < minCloseConfs { - expectedConfs = minCloseConfs - } - - require.Equal( - t, expectedConfs, closeConfs, - "CloseConfsForCapacity should match "+ - "ScaleNumConfs with 0 push amount, "+ - "but with minimum of 3 confs", - ) - }) -} diff --git a/peer/brontide.go b/peer/brontide.go index 61e638f6b..9191cbb2e 100644 --- a/peer/brontide.go +++ b/peer/brontide.go @@ -370,12 +370,6 @@ type Config struct { // closure initiated by the remote peer. CoopCloseTargetConfs uint32 - // ChannelCloseConfs is an optional override for the number of - // confirmations required for channel closes. When set, this overrides - // the normal capacity-based scaling. This is only available in - // dev/integration builds for testing purposes. - ChannelCloseConfs fn.Option[uint32] - // ServerPubKey is the serialized, compressed public key of our lnd node. // It is used to determine which policy (channel edge) to pass to the // ChannelLink. @@ -4450,22 +4444,9 @@ func (p *Brontide) finalizeChanClosure(chanCloser *chancloser.ChanCloser) { localOut := chanCloser.LocalCloseOutput() remoteOut := chanCloser.RemoteCloseOutput() auxOut := chanCloser.AuxOutputs() - - // Determine the number of confirmations to wait before signaling a - // successful cooperative close, scaled by channel capacity (see - // CloseConfsForCapacity). Check if we have a config override for - // testing purposes. - chanCapacity := chanCloser.Channel().Capacity - numConfs := p.cfg.ChannelCloseConfs.UnwrapOrFunc(func() uint32 { - // No override, use normal capacity-based scaling. - return lnwallet.CloseConfsForCapacity(chanCapacity) - }) - - // Register for full confirmation to send the final update. - closeScript := closingTx.TxOut[0].PkScript go WaitForChanToClose( chanCloser.NegotiationHeight(), notifier, errChan, - &chanPoint, &closingTxid, closeScript, numConfs, func() { + &chanPoint, &closingTxid, closingTx.TxOut[0].PkScript, func() { // Respond to the local subsystem which requested the // channel closure. if closeReq != nil { @@ -4488,14 +4469,14 @@ func (p *Brontide) finalizeChanClosure(chanCloser *chancloser.ChanCloser) { // the function, then it will be sent over the errChan. func WaitForChanToClose(bestHeight uint32, notifier chainntnfs.ChainNotifier, errChan chan error, chanPoint *wire.OutPoint, - closingTxID *chainhash.Hash, closeScript []byte, numConfs uint32, - cb func()) { + closingTxID *chainhash.Hash, closeScript []byte, cb func()) { peerLog.Infof("Waiting for confirmation of close of ChannelPoint(%v) "+ "with txid: %v", chanPoint, closingTxID) + // TODO(roasbeef): add param for num needed confs confNtfn, err := notifier.RegisterConfirmationsNtfn( - closingTxID, closeScript, numConfs, bestHeight, + closingTxID, closeScript, 1, bestHeight, ) if err != nil { if errChan != nil { diff --git a/routing/router.go b/routing/router.go index 19df5b921..3c35b7c52 100644 --- a/routing/router.go +++ b/routing/router.go @@ -54,8 +54,8 @@ const ( // creating incompatibilities during the upgrade process. For some time // LND has used an explicit default final CLTV delta of 40 blocks for // bitcoin, though we now clamp the lower end of this - // range for user-chosen deltas to 24 blocks to be conservative. - MinCLTVDelta = 24 + // range for user-chosen deltas to 18 blocks to be conservative. + MinCLTVDelta = 18 // MaxCLTVDelta is the maximum CLTV value accepted by LND for all // timelock deltas. diff --git a/rpcserver.go b/rpcserver.go index 4189b8881..64eb40fb8 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -2807,16 +2807,9 @@ func (r *rpcServer) CloseChannel(in *lnrpc.CloseChannelRequest, errChan = make(chan error, 1) notifier := r.server.cc.ChainNotifier - - // For force closes, we notify the RPC client immediately after - // 1 confirmation. The actual security-critical confirmation - // waiting is handled by the channel arbitrator. - numConfs := uint32(1) - go peer.WaitForChanToClose( uint32(bestHeight), notifier, errChan, chanPoint, - &closingTxid, closingTx.TxOut[0].PkScript, numConfs, - func() { + &closingTxid, closingTx.TxOut[0].PkScript, func() { // Respond to the local subsystem which // requested the channel closure. updateChan <- &peer.ChannelCloseUpdate{ diff --git a/sample-lnd.conf b/sample-lnd.conf index eeb7a8123..f20035c86 100644 --- a/sample-lnd.conf +++ b/sample-lnd.conf @@ -1865,7 +1865,7 @@ ; DefaultIncomingBroadcastDelta set by lnd, otherwise the channel will be force ; closed anyway. A warning will be logged on startup if this value is not large ; enough to prevent force closes. -; invoices.holdexpirydelta=18 +; invoices.holdexpirydelta=12 [routing] diff --git a/server.go b/server.go index 26f54f85f..3f38e8855 100644 --- a/server.go +++ b/server.go @@ -1361,10 +1361,9 @@ func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr, return &pc.Incoming }, - AuxLeafStore: implCfg.AuxLeafStore, - AuxSigner: implCfg.AuxSigner, - AuxResolver: implCfg.AuxContractResolver, - ChannelCloseConfs: s.cfg.Dev.ChannelCloseConfs(), + AuxLeafStore: implCfg.AuxLeafStore, + AuxSigner: implCfg.AuxSigner, + AuxResolver: implCfg.AuxContractResolver, }, dbs.ChanStateDB) // Select the configuration and funding parameters for Bitcoin. @@ -1469,6 +1468,16 @@ func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr, DefaultMinHtlcIn: cc.MinHtlcIn, NumRequiredConfs: func(chanAmt btcutil.Amount, pushAmt lnwire.MilliSatoshi) uint16 { + // For large channels we increase the number + // of confirmations we require for the + // channel to be considered open. As it is + // always the responder that gets to choose + // value, the pushAmt is value being pushed + // to us. This means we have more to lose + // in the case this gets re-orged out, and + // we will require more confirmations before + // we consider it open. + // In case the user has explicitly specified // a default value for the number of // confirmations, we use it. @@ -1477,17 +1486,29 @@ func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr, return defaultConf } - // Otherwise, scale the number of confirmations based on - // the channel amount and push amount. For large - // channels we increase the number of - // confirmations we require for the channel to be - // considered open. As it is always the - // responder that gets to choose value, the - // pushAmt is value being pushed to us. This - // means we have more to lose in the case this - // gets re-orged out, and we will require more - // confirmations before we consider it open. - return lnwallet.FundingConfsForAmounts(chanAmt, pushAmt) + minConf := uint64(3) + maxConf := uint64(6) + + // If this is a wumbo channel, then we'll require the + // max amount of confirmations. + if chanAmt > MaxFundingAmount { + return uint16(maxConf) + } + + // If not we return a value scaled linearly + // between 3 and 6, depending on channel size. + // TODO(halseth): Use 1 as minimum? + maxChannelSize := uint64( + lnwire.NewMSatFromSatoshis(MaxFundingAmount)) + stake := lnwire.NewMSatFromSatoshis(chanAmt) + pushAmt + conf := maxConf * uint64(stake) / maxChannelSize + if conf < minConf { + conf = minConf + } + if conf > maxConf { + conf = maxConf + } + return uint16(conf) }, RequiredRemoteDelay: func(chanAmt btcutil.Amount) uint16 { // We scale the remote CSV delay (the time the @@ -4389,7 +4410,6 @@ func (s *server) peerConnected(conn net.Conn, connReq *connmgr.ConnReq, MaxOutgoingCltvExpiry: s.cfg.MaxOutgoingCltvExpiry, MaxChannelFeeAllocation: s.cfg.MaxChannelFeeAllocation, CoopCloseTargetConfs: s.cfg.CoopCloseTargetConfs, - ChannelCloseConfs: s.cfg.Dev.ChannelCloseConfs(), MaxAnchorsCommitFeeRate: chainfee.SatPerKVByte( s.cfg.MaxCommitFeeRateAnchors * 1000).FeePerKWeight(), ChannelCommitInterval: s.cfg.ChannelCommitInterval, diff --git a/zpay32/hophint.go b/zpay32/hophint.go index dd1a2eebe..07872b0d6 100644 --- a/zpay32/hophint.go +++ b/zpay32/hophint.go @@ -12,7 +12,7 @@ const ( // We adhere to the recommendation in BOLT 02 for terminal payments. // See also: // https://github.com/lightning/bolts/blob/master/02-peer-protocol.md - DefaultAssumedFinalCLTVDelta = 24 + DefaultAssumedFinalCLTVDelta = 18 // feeRateParts is the total number of parts used to express fee rates. feeRateParts = 1e6 From 3be1baf9cf4ffe3f9ae5bcf21dbaae8b28969f1f Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Mon, 2 Feb 2026 17:15:34 -0400 Subject: [PATCH 082/134] build: bump version to v0.20.1 rc2 --- build/version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/version.go b/build/version.go index b9ed3be11..a9340a0ee 100644 --- a/build/version.go +++ b/build/version.go @@ -51,7 +51,7 @@ const ( // AppPreRelease MUST only contain characters from semanticAlphabet per // the semantic versioning spec. - AppPreRelease = "beta.rc1" + AppPreRelease = "beta.rc2" ) func init() { From fe486e13a931b4655174296e178f5715749ba636 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Wed, 28 Jan 2026 23:22:13 -0400 Subject: [PATCH 083/134] graphdb: fix backwards-compat for channel edge feature deserialization This commit fixes a backwards compatibility issue that prevented nodes from upgrading from v0.19.x to v0.20.x. In v0.19.x, channel edge features were serialized as raw feature bytes without a length prefix. In v0.20.x (commit 2f2845dfc), the serialization changed to use Features.Encode() which adds a 2-byte big-endian length prefix before the feature bits. The deserialization code was updated to use Features.Decode() which expects this length prefix. When v0.20.x reads a database created by v0.19.x, Decode() tries to read a length prefix that doesn't exist, causing an EOF error: unable to decode features: EOF The fix adds a deserializeChanEdgeFeatures() helper that detects which format is being read and decodes accordingly: - New format (v0.20+): First 2 bytes encode the length of the remaining bytes. Detected when uint16(bytes[0:2]) == len(bytes)-2. - Legacy format (pre-v0.20): Raw feature bits without length prefix. Uses DecodeBase256 with the known length. The format detection is safe because in the legacy format, the first byte always has at least one bit set (the serialization uses minimum bytes), so the first two bytes can never encode a value equal to len-2. Fixes #10528. (cherry picked from commit 56a7f45b998054ad290ecfe3ede57f274b58a5b4) --- docs/release-notes/release-notes-0.20.1.md | 7 + graph/db/kv_store.go | 79 +++- graph/db/kv_store_features_test.go | 449 +++++++++++++++++++++ 3 files changed, 530 insertions(+), 5 deletions(-) create mode 100644 graph/db/kv_store_features_test.go diff --git a/docs/release-notes/release-notes-0.20.1.md b/docs/release-notes/release-notes-0.20.1.md index 0d39f503e..45ba4b4f8 100644 --- a/docs/release-notes/release-notes-0.20.1.md +++ b/docs/release-notes/release-notes-0.20.1.md @@ -75,6 +75,13 @@ the EstimateRouteFee API can probe Eclair and LDK nodes which enforce the payment address/secret. +* [Fix backwards compatibility for channel edge feature + deserialization](https://github.com/lightningnetwork/lnd/pull/10529). Nodes + upgrading from pre-v0.20 versions could fail to read channel edges from their + graph database due to a format change in how channel features are serialized. + The fix adds automatic format detection to handle both legacy (raw feature + bits) and new (length-prefixed) formats. + # New Features ## Functional Enhancements diff --git a/graph/db/kv_store.go b/graph/db/kv_store.go index e47f8e28f..7a572dbd9 100644 --- a/graph/db/kv_store.go +++ b/graph/db/kv_store.go @@ -4723,6 +4723,78 @@ func fetchChanEdgeInfo(edgeIndex kvdb.RBucket, return deserializeChanEdgeInfo(edgeInfoReader) } +// deserializeChanEdgeFeatures deserializes channel edge features from bytes, +// handling both the legacy format (raw feature bits) and the current format +// (2-byte length prefix followed by feature bits). +// +// Legacy format (pre-v0.20): VarBytes containing raw feature bits directly. +// Current format (v0.20+): VarBytes containing a 2-byte big-endian length +// followed by the feature bits. +// +// The format is detected by checking if the first 2 bytes, interpreted as a +// big-endian uint16 length, equals len(featureBytes)-2. Since this length +// check alone could have false positives (e.g., a 258-byte legacy vector +// starting with 0x01, 0x00), we additionally verify by decoding and +// re-encoding the payload to confirm it produces the exact same bytes +// (canonical encoding check). +func deserializeChanEdgeFeatures(featureBytes []byte) (*lnwire.FeatureVector, + error) { + + features := lnwire.NewRawFeatureVector() + + // Empty features are valid in both formats. + if len(featureBytes) == 0 { + return lnwire.NewFeatureVector(features, lnwire.Features), nil + } + + // Check if this looks like the new format with a 2-byte length prefix. + // In the new format, the first 2 bytes encode the length of the + // remaining feature bytes. + if len(featureBytes) >= 2 { + encodedLen := binary.BigEndian.Uint16(featureBytes[:2]) + if int(encodedLen) == len(featureBytes)-2 { + // This looks like it could be the new format. To be + // certain, we decode and re-encode to verify canonical + // encoding, as a legacy feature vector could + // accidentally match the length check (e.g., a 258-byte + // legacy vector starting with 0x01, 0x00 would have + // 256 == 258 - 2). + payload := featureBytes[2:] + tempFeatures := lnwire.NewRawFeatureVector() + err := tempFeatures.DecodeBase256( + bytes.NewReader(payload), int(encodedLen), + ) + + var checkBuf bytes.Buffer + if err == nil { + err = tempFeatures.EncodeBase256(&checkBuf) + } + + // If there were no errors and the re-encoded payload + // matches the original, we are confident it's the new + // format. + isCanonical := bytes.Equal(checkBuf.Bytes(), payload) + if err == nil && isCanonical { + return lnwire.NewFeatureVector( + tempFeatures, lnwire.Features, + ), nil + } + } + } + + // Legacy format: the bytes are raw feature bits without a length + // prefix. + err := features.DecodeBase256( + bytes.NewReader(featureBytes), len(featureBytes), + ) + if err != nil { + return nil, fmt.Errorf("unable to decode features "+ + "(legacy format): %w", err) + } + + return lnwire.NewFeatureVector(features, lnwire.Features), nil +} + func deserializeChanEdgeInfo(r io.Reader) (models.ChannelEdgeInfo, error) { var ( err error @@ -4747,13 +4819,10 @@ func deserializeChanEdgeInfo(r io.Reader) (models.ChannelEdgeInfo, error) { return models.ChannelEdgeInfo{}, err } - features := lnwire.NewRawFeatureVector() - err = features.Decode(bytes.NewReader(featureBytes)) + edgeInfo.Features, err = deserializeChanEdgeFeatures(featureBytes) if err != nil { - return models.ChannelEdgeInfo{}, fmt.Errorf("unable to decode "+ - "features: %w", err) + return models.ChannelEdgeInfo{}, err } - edgeInfo.Features = lnwire.NewFeatureVector(features, lnwire.Features) proof := &models.ChannelAuthProof{} diff --git a/graph/db/kv_store_features_test.go b/graph/db/kv_store_features_test.go new file mode 100644 index 000000000..161ea59a2 --- /dev/null +++ b/graph/db/kv_store_features_test.go @@ -0,0 +1,449 @@ +package graphdb + +import ( + "bytes" + "encoding/binary" + "testing" + + "github.com/lightningnetwork/lnd/lnwire" + "github.com/stretchr/testify/require" + "pgregory.net/rapid" +) + +// TestDeserializeChanEdgeFeaturesEmpty tests that empty feature bytes are +// handled correctly for both legacy and new formats. +func TestDeserializeChanEdgeFeaturesEmpty(t *testing.T) { + t.Parallel() + + // Empty bytes should result in empty features. + features, err := deserializeChanEdgeFeatures(nil) + require.NoError(t, err) + require.True(t, features.IsEmpty()) + + features, err = deserializeChanEdgeFeatures([]byte{}) + require.NoError(t, err) + require.True(t, features.IsEmpty()) + + // New format with zero-length features: [0x00, 0x00]. + features, err = deserializeChanEdgeFeatures([]byte{0x00, 0x00}) + require.NoError(t, err) + require.True(t, features.IsEmpty()) +} + +// TestDeserializeChanEdgeFeaturesLegacyFormat tests deserialization of +// feature bytes written in the legacy format (pre-v0.20), which contains +// raw feature bits without a 2-byte length prefix. +func TestDeserializeChanEdgeFeaturesLegacyFormat(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + legacyBytes []byte + expectedFeats []lnwire.FeatureBit + }{ + { + name: "single byte - bit 0", + legacyBytes: []byte{0x01}, // bit 0 set + expectedFeats: []lnwire.FeatureBit{0}, + }, + { + name: "single byte - bit 7", + legacyBytes: []byte{0x80}, // bit 7 set + expectedFeats: []lnwire.FeatureBit{7}, + }, + { + name: "single byte - multiple bits", + legacyBytes: []byte{0x25}, // bits 0, 2, 5 set + expectedFeats: []lnwire.FeatureBit{0, 2, 5}, + }, + { + name: "two bytes - bit 8", + legacyBytes: []byte{0x01, 0x00}, // bit 8 set + expectedFeats: []lnwire.FeatureBit{8}, + }, + { + name: "two bytes - bits 0 and 15", + legacyBytes: []byte{0x80, 0x01}, // bits 0 and 15 set + expectedFeats: []lnwire.FeatureBit{0, 15}, + }, + { + // bit 1 (DataLossProtectOptional). + name: "common features - data loss protect", + legacyBytes: []byte{0x02}, + expectedFeats: []lnwire.FeatureBit{ + lnwire.DataLossProtectOptional, + }, + }, + { + // bits 1, 7, 9, 13, 15 = DataLossProtect, + // GossipQueries, TLVOnion, StaticRemoteKey, + // PaymentAddr. + name: "multiple common features", + legacyBytes: []byte{0xA2, 0x82}, + expectedFeats: []lnwire.FeatureBit{1, 7, 9, 13, 15}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + features, err := deserializeChanEdgeFeatures( + tc.legacyBytes, + ) + require.NoError(t, err) + + for _, bit := range tc.expectedFeats { + require.True(t, features.IsSet(bit), + "expected bit %d to be set", bit) + } + + // Verify no extra bits are set by creating expected + // feature vector and comparing. + expectedRaw := lnwire.NewRawFeatureVector( + tc.expectedFeats..., + ) + require.True(t, expectedRaw.Equals( + features.RawFeatureVector), + "feature vectors don't match") + }) + } +} + +// TestDeserializeChanEdgeFeaturesNewFormat tests deserialization of +// feature bytes written in the new format (v0.20+), which contains +// a 2-byte big-endian length prefix followed by raw feature bits. +func TestDeserializeChanEdgeFeaturesNewFormat(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + expectedFeats []lnwire.FeatureBit + }{ + { + name: "empty features", + expectedFeats: nil, + }, + { + name: "single feature bit 0", + expectedFeats: []lnwire.FeatureBit{0}, + }, + { + name: "single feature bit 15", + expectedFeats: []lnwire.FeatureBit{15}, + }, + { + name: "multiple features", + expectedFeats: []lnwire.FeatureBit{1, 5, 9, 13, 17}, + }, + { + name: "common lightning features", + expectedFeats: []lnwire.FeatureBit{ + lnwire.DataLossProtectOptional, + lnwire.GossipQueriesOptional, + lnwire.TLVOnionPayloadOptional, + lnwire.StaticRemoteKeyOptional, + lnwire.PaymentAddrOptional, + }, + }, + { + name: "high bit features", + expectedFeats: []lnwire.FeatureBit{ + lnwire.AMPOptional, // 31 + lnwire.KeysendOptional, // 55 + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Create feature vector and encode in new format. + rawFeatures := lnwire.NewRawFeatureVector( + tc.expectedFeats..., + ) + fv := lnwire.NewFeatureVector( + rawFeatures, lnwire.Features, + ) + + // Encode using the new format (with length prefix). + var buf bytes.Buffer + err := fv.Encode(&buf) + require.NoError(t, err) + + // Deserialize and verify. + features, err := deserializeChanEdgeFeatures( + buf.Bytes(), + ) + require.NoError(t, err) + + for _, bit := range tc.expectedFeats { + require.True(t, features.IsSet(bit), + "expected bit %d to be set", bit) + } + + // Verify feature equality. + require.True(t, rawFeatures.Equals( + features.RawFeatureVector), + ) + }) + } +} + +// TestDeserializeChanEdgeFeaturesFormatDetection tests that the format +// detection correctly distinguishes between legacy and new formats. +func TestDeserializeChanEdgeFeaturesFormatDetection(t *testing.T) { + t.Parallel() + + // Test that legacy format bytes that could theoretically be confused + // with new format are handled correctly. This shouldn't happen in + // practice because in legacy format the first byte always has at least + // one bit set (the highest feature bit determines the byte length). + + // Create a feature vector with bit 8 set (requires 2 bytes in legacy). + // Legacy format: [0x01, 0x00] (big-endian, high byte first). + // As a length, 0x0100 = 256, which != 0 (len-2), so correctly detected + // as legacy. + legacyBit8 := []byte{0x01, 0x00} + features, err := deserializeChanEdgeFeatures(legacyBit8) + require.NoError(t, err) + require.True(t, features.IsSet(8)) + require.False(t, features.IsSet(0)) + + // New format with bit 8: [0x00, 0x02, 0x01, 0x00] + // Length prefix 0x0002 = 2, remaining 2 bytes = feature bits. + newFormatBit8 := []byte{0x00, 0x02, 0x01, 0x00} + features, err = deserializeChanEdgeFeatures(newFormatBit8) + require.NoError(t, err) + require.True(t, features.IsSet(8)) + require.False(t, features.IsSet(0)) + + // Test single byte legacy format - cannot be confused with new format + // since new format minimum is 2 bytes (the length prefix). + legacyBit0 := []byte{0x01} + features, err = deserializeChanEdgeFeatures(legacyBit0) + require.NoError(t, err) + require.True(t, features.IsSet(0)) +} + +// TestDeserializeChanEdgeFeaturesRoundTrip tests that features can be +// serialized and deserialized correctly using the new format. +func TestDeserializeChanEdgeFeaturesRoundTrip(t *testing.T) { + t.Parallel() + + testFeatureSets := [][]lnwire.FeatureBit{ + {}, + {0}, + {7}, + {8}, + {15}, + {0, 1, 2, 3, 4, 5, 6, 7}, + {8, 9, 10, 11, 12, 13, 14, 15}, + {0, 8, 16, 24, 32}, + { + lnwire.DataLossProtectOptional, + lnwire.GossipQueriesOptional, + lnwire.TLVOnionPayloadOptional, + lnwire.StaticRemoteKeyOptional, + lnwire.PaymentAddrOptional, + lnwire.MPPOptional, + lnwire.AnchorsZeroFeeHtlcTxOptional, + }, + } + + for _, featureBits := range testFeatureSets { + // Create and encode. + rawFeatures := lnwire.NewRawFeatureVector(featureBits...) + fv := lnwire.NewFeatureVector(rawFeatures, lnwire.Features) + + var buf bytes.Buffer + err := fv.Encode(&buf) + require.NoError(t, err) + + // Deserialize. + decoded, err := deserializeChanEdgeFeatures(buf.Bytes()) + require.NoError(t, err) + + // Verify equality. + require.True(t, rawFeatures.Equals(decoded.RawFeatureVector), + "mismatch for features %v", featureBits) + } +} + +// TestDeserializeChanEdgeFeaturesPropertyBased uses property-based testing +// to verify that the deserialization works correctly for arbitrary feature +// combinations in both legacy and new formats. +func TestDeserializeChanEdgeFeaturesPropertyBased(t *testing.T) { + t.Parallel() + + // Test legacy format: raw feature bytes without length prefix. + rapid.Check(t, func(t *rapid.T) { + // Generate random feature bits (max 256 to keep reasonable). + numFeatures := rapid.IntRange(0, 20).Draw(t, "numFeatures") + featureBits := make([]lnwire.FeatureBit, numFeatures) + for i := 0; i < numFeatures; i++ { + featureBits[i] = lnwire.FeatureBit( + rapid.IntRange(0, 255).Draw(t, "featureBit"), + ) + } + + // Create feature vector. + rawFeatures := lnwire.NewRawFeatureVector(featureBits...) + + // Encode without length prefix (legacy format). + var buf bytes.Buffer + err := rawFeatures.EncodeBase256(&buf) + require.NoError(t, err) + + // Deserialize. + decoded, err := deserializeChanEdgeFeatures(buf.Bytes()) + require.NoError(t, err) + + // Verify equality. + require.True(t, rawFeatures.Equals(decoded.RawFeatureVector)) + }) + + // Test new format: with length prefix. + rapid.Check(t, func(t *rapid.T) { + // Generate random feature bits. + numFeatures := rapid.IntRange(0, 20).Draw(t, "numFeatures") + featureBits := make([]lnwire.FeatureBit, numFeatures) + for i := 0; i < numFeatures; i++ { + featureBits[i] = lnwire.FeatureBit( + rapid.IntRange(0, 255).Draw(t, "featureBit"), + ) + } + + // Create feature vector. + rawFeatures := lnwire.NewRawFeatureVector(featureBits...) + fv := lnwire.NewFeatureVector(rawFeatures, lnwire.Features) + + // Encode with length prefix (new format). + var buf bytes.Buffer + err := fv.Encode(&buf) + require.NoError(t, err) + + // Deserialize. + decoded, err := deserializeChanEdgeFeatures(buf.Bytes()) + require.NoError(t, err) + + // Verify equality. + require.True(t, rawFeatures.Equals(decoded.RawFeatureVector)) + }) +} + +// TestDeserializeChanEdgeFeaturesLegacyFormatNoCollision verifies that +// the format detection cannot have false positives where legacy format +// bytes are incorrectly detected as new format. +func TestDeserializeChanEdgeFeaturesLegacyFormatNoCollision(t *testing.T) { + t.Parallel() + + // The detection works through canonical encoding verification. + // Even if a legacy vector accidentally matches the length check + // (e.g., a 258-byte vector starting with 0x01, 0x00), the decode/ + // re-encode check will fail because the legacy encoding won't be + // canonical when interpreted as new format payload. + + rapid.Check(t, func(t *rapid.T) { + // Generate feature bits with higher range to catch more edge + // cases, including vectors that could match the length check. + maxBit := rapid.IntRange(0, 2200).Draw(t, "maxBit") + numExtra := rapid.IntRange(0, 10).Draw(t, "numExtra") + + featureBits := []lnwire.FeatureBit{lnwire.FeatureBit(maxBit)} + for i := 0; i < numExtra; i++ { + bit := rapid.IntRange(0, maxBit).Draw(t, "extraBit") + featureBits = append(featureBits, + lnwire.FeatureBit(bit)) + } + + rawFeatures := lnwire.NewRawFeatureVector(featureBits...) + + // Encode in legacy format. + var buf bytes.Buffer + err := rawFeatures.EncodeBase256(&buf) + require.NoError(t, err) + + legacyBytes := buf.Bytes() + if len(legacyBytes) < 2 { + // Single byte can't be confused with new format. + return + } + + // Verify deserialization still works correctly regardless of + // whether the length check happens to match. + decoded, err := deserializeChanEdgeFeatures(legacyBytes) + require.NoError(t, err) + require.True(t, rawFeatures.Equals(decoded.RawFeatureVector), + "mismatch for legacy bytes %x with maxBit %d", + legacyBytes, maxBit) + }) +} + +// TestDeserializeChanEdgeFeaturesLengthCheckCollision specifically tests the +// edge case where a legacy feature vector accidentally satisfies the length +// check condition (first 2 bytes as uint16 == len - 2). This can happen with +// a 258-byte vector starting with 0x01, 0x00, where 256 == 258 - 2. +// The canonical encoding verification should correctly identify this as legacy +// format. +func TestDeserializeChanEdgeFeaturesLengthCheckCollision(t *testing.T) { + t.Parallel() + + // Create a legacy feature vector that will produce bytes where the + // first two bytes, interpreted as a length, equal len - 2. + // + // To get 258 bytes in legacy format, we need bit 2063 set (258*8-1). + // The first byte will be 0x01 (bit 2056 is in byte 0, and we need + // bit 2063 which is 0x80, but the bytes are big-endian so byte 0 + // contains the high bits). Actually let's work this out: + // + // For 258 bytes, bits 2056-2063 are in byte 0. + // Setting bit 2056 gives byte[0] = 0x01. + // If byte[0] = 0x01 and byte[1] = 0x00, then as uint16 = 256 = 258-2. + // + // So we need: bit 2056 set (gives 0x01 in byte 0), and no bits in + // byte 1 set (bits 2048-2055), and at least one bit set below to + // ensure we have full 258 bytes (bit 0 to ensure byte 257 is non-zero + // won't work since it affects the last byte...). + // + // Actually the encoding is that the first byte contains the HIGHEST + // bits. So for 258 bytes: + // - byte[0] contains bits 2056-2063 + // - byte[1] contains bits 2048-2055 + // - ... + // - byte[257] contains bits 0-7 + // + // To get byte[0] = 0x01 and byte[1] = 0x00: + // - Set bit 2056 (gives 0x01 in byte 0) + // - Don't set bits 2048-2055 (keeps byte 1 = 0x00) + // + // We also need to set some lower bit to have meaningful features. + featureBits := []lnwire.FeatureBit{ + 2056, // This gives 0x01 in first byte (258 bytes total) + 0, // Set bit 0 for a meaningful feature + } + + rawFeatures := lnwire.NewRawFeatureVector(featureBits...) + + // Encode in legacy format. + var buf bytes.Buffer + err := rawFeatures.EncodeBase256(&buf) + require.NoError(t, err) + + legacyBytes := buf.Bytes() + require.Len(t, legacyBytes, 258, "expected 258 bytes for bit 2056") + + // Verify the collision condition: first 2 bytes as uint16 == len - 2. + encodedLen := binary.BigEndian.Uint16(legacyBytes[:2]) + require.Equal(t, uint16(256), encodedLen, + "expected first 2 bytes to encode 256") + require.Equal(t, 256, len(legacyBytes)-2, + "expected length check to match") + + // Despite the length check matching, deserialization should still + // correctly identify this as legacy format (via canonical encoding + // verification) and decode it properly. + decoded, err := deserializeChanEdgeFeatures(legacyBytes) + require.NoError(t, err) + require.True(t, decoded.IsSet(2056), "bit 2056 should be set") + require.True(t, decoded.IsSet(0), "bit 0 should be set") + require.True(t, rawFeatures.Equals(decoded.RawFeatureVector), + "feature vectors should match") +} From 81d4da7ffd9bc799b46fbf0b1bbaaf916388f7bd Mon Sep 17 00:00:00 2001 From: Matt Morehouse Date: Mon, 2 Feb 2026 16:00:37 -0600 Subject: [PATCH 084/134] discovery: fix gossiper shutdown deadlock When processing a remote network announcement, it is possible for two error messages to be sent back on the errChan. Since Brontide doesn't actually read from errChan, and since errChan only buffered one error message, the sending goroutine would deadlock forever. This would only become apparent when the gossiper attempted to shut down and got hung up. For now, we can fix this simply by buffering up to two error messages on errChan. There is an existing TODO to restructure this logic entirely to use the actor model, and we can do a more thorough fix as part of that work. This bug was discovered while doing full node fuzz testing and was triggered by sending a specific channel_announcement message and then shutting down LND. (cherry picked from commit 21588acb3d5b9b4bb605b0852c91f10b6bb2a1c6) --- discovery/gossiper.go | 9 +++++- discovery/gossiper_test.go | 61 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/discovery/gossiper.go b/discovery/gossiper.go index d62d66991..5400abf4c 100644 --- a/discovery/gossiper.go +++ b/discovery/gossiper.go @@ -876,7 +876,14 @@ func (d *AuthenticatedGossiper) stop() { func (d *AuthenticatedGossiper) ProcessRemoteAnnouncement(ctx context.Context, msg lnwire.Message, peer lnpeer.Peer) chan error { - errChan := make(chan error, 1) + // Buffer up to two messages on errChan since up to two messages may be + // written and not all callers of this function actually read from + // errChan. Without this buffer goroutines end up blocking on writes to + // errChan, which prevents the gossiper from shutting down cleanly. + // + // TODO(ziggie): Redesign this once the actor model pattern becomes + // available. See https://github.com/lightningnetwork/lnd/pull/9820. + errChan := make(chan error, 2) // For messages in the known set of channel series queries, we'll // dispatch the message directly to the GossipSyncer, and skip the main diff --git a/discovery/gossiper_test.go b/discovery/gossiper_test.go index 2776d4b3a..e8c59fe14 100644 --- a/discovery/gossiper_test.go +++ b/discovery/gossiper_test.go @@ -5235,3 +5235,64 @@ func TestRecoverGossipPanicNilJobID(t *testing.T) { t.Fatal("timeout waiting for error") } } + +// TestGossiperShutdownWrongChainAnnouncement tests that the gossiper can shut +// down cleanly after processing a channel announcement with the wrong chain +// hash. This is a regression test for a bug where the gossiper would deadlock +// on shutdown because more errors were sent on the error channel than it would +// buffer, and no one was reading those error messages. +// +// In this test we trigger the sending of two error messages: +// 1. First send when rejecting the wrong-chain announcement +// 2. Second send when SignalDependents returns an error +// +// Since the error channel had a buffer of 1, the second send would block +// forever, preventing the goroutine from completing and causing Stop() to hang +// on wg.Wait(). +func TestGossiperShutdownWrongChainAnnouncement(t *testing.T) { + t.Parallel() + + // Create a test context with the gossiper configured for MainNet. + tCtx, err := createTestCtx(t, 0, false) + require.NoError(t, err) + + // Create a channel announcement with: + // 1. Wrong chain hash (SimNet instead of MainNet) + // 2. NodeID1 == NodeID2 + // + // The first condition triggers the first error message to be sent, and + // the second condition causes SignalDependents to attempt to remove the + // same dependent job twice, which then triggers the second error + // message to be sent. + wrongChainAnn := &lnwire.ChannelAnnouncement1{ + ChainHash: *chaincfg.SimNetParams.GenesisHash, + ShortChannelID: lnwire.ShortChannelID{ + BlockHeight: 1, + TxIndex: 0, + TxPosition: 0, + }, + Features: testFeatures, + } + // Use the SAME public key for NodeID1 and NodeID2 to trigger the + // second error message. + copy(wrongChainAnn.NodeID1[:], remoteKeyPub1.SerializeCompressed()) + copy(wrongChainAnn.NodeID2[:], remoteKeyPub1.SerializeCompressed()) + copy(wrongChainAnn.BitcoinKey1[:], bitcoinKeyPub1.SerializeCompressed()) + copy(wrongChainAnn.BitcoinKey2[:], bitcoinKeyPub2.SerializeCompressed()) + + nodePeer := &mockPeer{remoteKeyPub1, nil, nil, atomic.Bool{}} + + // Process the announcement without reading from the error channel, + // exactly as Brontide does. + _ = tCtx.gossiper.ProcessRemoteAnnouncement( + t.Context(), wrongChainAnn, nodePeer, + ) + + // Give the gossiper time to process the announcement. + time.Sleep(100 * time.Millisecond) + + // Now stop the gossiper. This should complete without hanging. + // If the bug is present, Stop() will hang forever because a goroutine + // is blocked trying to send to the error channel a second time. + require.NoError(t, tCtx.gossiper.Stop()) +} From f427fee341948aa48b0faf1713378b0e9314ec21 Mon Sep 17 00:00:00 2001 From: Matt Morehouse Date: Tue, 3 Feb 2026 09:12:56 -0600 Subject: [PATCH 085/134] docs: add release note for #10540 --- docs/release-notes/release-notes-0.20.1.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/release-notes/release-notes-0.20.1.md b/docs/release-notes/release-notes-0.20.1.md index 45ba4b4f8..02dd849f6 100644 --- a/docs/release-notes/release-notes-0.20.1.md +++ b/docs/release-notes/release-notes-0.20.1.md @@ -82,6 +82,12 @@ The fix adds automatic format detection to handle both legacy (raw feature bits) and new (length-prefixed) formats. +* [Fixed a shutdown + deadlock](https://github.com/lightningnetwork/lnd/pull/10540) in the gossiper. + Certain gossip messages could cause multiple error messages to be sent on a + channel that was only expected to be used for a single message. The erring + goroutine would block on the second send, leading to a deadlock at shutdown. + # New Features ## Functional Enhancements @@ -152,4 +158,5 @@ * Abdulkbk * bitromortac +* Matt Morehouse * Ziggie From b6bd8c4fd251d143c98fea634eb065639580c72e Mon Sep 17 00:00:00 2001 From: ziggie Date: Thu, 5 Feb 2026 15:55:40 -0500 Subject: [PATCH 086/134] mod: pin sqldb to pseudo-version for v0.20.x release This removes the local replace directive for the sqldb package and pins it to v1.0.12-0.20260113193010-8565d12e40b1 (commit 8565d12e4). --- go.mod | 6 +----- go.sum | 2 ++ 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index ac06f8b6e..f365f3ed5 100644 --- a/go.mod +++ b/go.mod @@ -39,7 +39,7 @@ require ( github.com/lightningnetwork/lnd/healthcheck v1.2.6 github.com/lightningnetwork/lnd/kvdb v1.4.16 github.com/lightningnetwork/lnd/queue v1.1.1 - github.com/lightningnetwork/lnd/sqldb v1.0.11 + github.com/lightningnetwork/lnd/sqldb v1.0.12-0.20260113193010-8565d12e40b1 github.com/lightningnetwork/lnd/ticker v1.1.1 github.com/lightningnetwork/lnd/tlv v1.3.2 github.com/lightningnetwork/lnd/tor v1.1.6 @@ -202,10 +202,6 @@ require ( sigs.k8s.io/yaml v1.2.0 // indirect ) -// Use the local sqldb package for development. -// TODO(norbert): remove once sqldb package is tagged. -replace github.com/lightningnetwork/lnd/sqldb => ./sqldb - // This replace is for https://github.com/advisories/GHSA-25xm-hr59-7c27 replace github.com/ulikunitz/xz => github.com/ulikunitz/xz v0.5.11 diff --git a/go.sum b/go.sum index 4507c5302..318c75694 100644 --- a/go.sum +++ b/go.sum @@ -382,6 +382,8 @@ github.com/lightningnetwork/lnd/kvdb v1.4.16 h1:9BZgWdDfjmHRHLS97cz39bVuBAqMc4/p github.com/lightningnetwork/lnd/kvdb v1.4.16/go.mod h1:HW+bvwkxNaopkz3oIgBV6NEnV4jCEZCACFUcNg4xSjM= github.com/lightningnetwork/lnd/queue v1.1.1 h1:99ovBlpM9B0FRCGYJo6RSFDlt8/vOkQQZznVb18iNMI= github.com/lightningnetwork/lnd/queue v1.1.1/go.mod h1:7A6nC1Qrm32FHuhx/mi1cieAiBZo5O6l8IBIoQxvkz4= +github.com/lightningnetwork/lnd/sqldb v1.0.12-0.20260113193010-8565d12e40b1 h1:PkEppKL17cZh0Dr9h/T9BEVJUbd/p2tjJ/x8ffG3R0M= +github.com/lightningnetwork/lnd/sqldb v1.0.12-0.20260113193010-8565d12e40b1/go.mod h1:tB2jlqu79TIOR9uhAZOmPxpVFUhB2s+oxKnqRRL1oc0= github.com/lightningnetwork/lnd/ticker v1.1.1 h1:J/b6N2hibFtC7JLV77ULQp++QLtCwT6ijJlbdiZFbSM= github.com/lightningnetwork/lnd/ticker v1.1.1/go.mod h1:waPTRAAcwtu7Ji3+3k+u/xH5GHovTsCoSVpho0KDvdA= github.com/lightningnetwork/lnd/tlv v1.3.2 h1:MO4FCk7F4k5xPMqVZF6Nb/kOpxlwPrUQpYjmyKny5s0= From f9035f74f52cd28cafc38914dd91fbc452ebec94 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Tue, 3 Feb 2026 17:54:17 -0800 Subject: [PATCH 087/134] build: bump version to v0.20.1 --- build/version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/version.go b/build/version.go index a9340a0ee..23245596a 100644 --- a/build/version.go +++ b/build/version.go @@ -51,7 +51,7 @@ const ( // AppPreRelease MUST only contain characters from semanticAlphabet per // the semantic versioning spec. - AppPreRelease = "beta.rc2" + AppPreRelease = "beta" ) func init() { From 0b04e339a4aa64f3da7c2825beca93cf0c710467 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 25 Jun 2026 13:36:43 -0300 Subject: [PATCH 088/134] rpcperms: recover RPC handler panics (cherry picked from commit 4bbfcab910c6ea08db9417dc54efa085daed2f27) --- rpcperms/interceptor.go | 153 ++++++++++++++++++++++++++++++++++- rpcperms/interceptor_test.go | 129 +++++++++++++++++++++++++++++ 2 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 rpcperms/interceptor_test.go diff --git a/rpcperms/interceptor.go b/rpcperms/interceptor.go index 9bbef0414..524e4bb7d 100644 --- a/rpcperms/interceptor.go +++ b/rpcperms/interceptor.go @@ -1,9 +1,11 @@ package rpcperms import ( + "bytes" "context" "errors" "fmt" + "runtime/debug" "sync" "sync/atomic" @@ -14,6 +16,8 @@ import ( "github.com/lightningnetwork/lnd/monitoring" "github.com/lightningnetwork/lnd/subscribe" "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "gopkg.in/macaroon-bakery.v2/bakery" ) @@ -111,6 +115,8 @@ var ( // +---v--------------------------------+ // | InterceptorChain | // +-+----------------------------------+ +// | Panic Recovery Interceptor | +// +----------------------------------+ // | Log Interceptor | // +----------------------------------+ // | RPC State Interceptor | @@ -536,7 +542,19 @@ func (r *InterceptorChain) CreateServerOpts() []grpc.ServerOption { var unaryInterceptors []grpc.UnaryServerInterceptor var strmInterceptors []grpc.StreamServerInterceptor - // The first interceptors we'll add to the chain is our logging + // The recovery interceptors need to be the outermost interceptors so + // synchronous panics in subsequent interceptors or RPC handlers are + // converted into an RPC error instead of crashing lnd. + unaryInterceptors = append( + unaryInterceptors, + panicRecoveryUnaryServerInterceptor(r.rpcsLog), + ) + strmInterceptors = append( + strmInterceptors, + panicRecoveryStreamServerInterceptor(r.rpcsLog), + ) + + // The next interceptors we'll add to the chain are our logging // interceptors, so we can automatically log all errors that happen // during RPC calls. unaryInterceptors = append( @@ -595,6 +613,139 @@ func (r *InterceptorChain) CreateServerOpts() []grpc.ServerOption { return serverOpts } +// logRecoveredPanic logs a panic caught while handling an RPC request. The +// stack trace is included to preserve enough information to debug the faulty +// handler while allowing lnd to keep running. +func logRecoveredPanic(logger btclog.Logger, fullMethod string, + panicValue any) { + + if logger == nil { + return + } + + if fullMethod == "" { + fullMethod = "" + } + + stack := truncatePanicStack(debug.Stack()) + + logger.Errorf("[%v]: recovered panic in RPC handler: %v\n%s", + fullMethod, panicValue, stack) +} + +const ( + // maxPanicStackSize is the maximum stack size logged for recovered RPC + // panics. This follows the existing 8 KiB recovered-panic stack bound + // convention while avoiding package coupling for a single constant. + maxPanicStackSize = 8192 + + panicStackTruncatedMsg = "\n... stack trace truncated ..." +) + +// truncatePanicStack caps a panic stack trace while keeping the final logged +// line readable when possible. +func truncatePanicStack(stack []byte) []byte { + if len(stack) <= maxPanicStackSize { + return stack + } + + suffix := []byte(panicStackTruncatedMsg) + maxStackLen := maxPanicStackSize - len(suffix) + searchStack := stack[:maxStackLen+1] + newLineIndex := bytes.LastIndexByte(searchStack, '\n') + if newLineIndex > 0 { + maxStackLen = newLineIndex + } + + truncatedStack := make([]byte, 0, maxStackLen+len(suffix)) + truncatedStack = append(truncatedStack, stack[:maxStackLen]...) + truncatedStack = append(truncatedStack, suffix...) + + return truncatedStack +} + +// panicRecoveryUnaryServerInterceptor recovers panics from unary RPC handlers +// and converts them to an internal gRPC error. +func panicRecoveryUnaryServerInterceptor( + logger btclog.Logger) grpc.UnaryServerInterceptor { + + return func(ctx context.Context, req any, + info *grpc.UnaryServerInfo, + handler grpc.UnaryHandler) (any, error) { + + var ( + resp any + err error + ) + + func() { + defer func() { + panicValue := recover() + if panicValue == nil { + return + } + + fullMethod := "" + if info != nil { + fullMethod = info.FullMethod + } + + logRecoveredPanic( + logger, fullMethod, panicValue, + ) + + resp = nil + err = status.Error( + codes.Internal, "internal server error", + ) + }() + + resp, err = handler(ctx, req) + }() + + return resp, err + } +} + +// panicRecoveryStreamServerInterceptor recovers panics from streaming RPC +// handlers and converts them to an internal gRPC error. +func panicRecoveryStreamServerInterceptor( + logger btclog.Logger) grpc.StreamServerInterceptor { + + return func(srv any, ss grpc.ServerStream, + info *grpc.StreamServerInfo, + handler grpc.StreamHandler) error { + + var err error + + func() { + defer func() { + panicValue := recover() + if panicValue == nil { + return + } + + fullMethod := "" + if info != nil { + fullMethod = info.FullMethod + } + + logRecoveredPanic( + logger, fullMethod, panicValue, + ) + + err = status.Error( + codes.Internal, "internal server error", + ) + }() + + err = handler(srv, ss) + }() + + return err + } +} + // errorLogUnaryServerInterceptor is a simple UnaryServerInterceptor that will // automatically log any errors that occur when serving a client's unary // request. diff --git a/rpcperms/interceptor_test.go b/rpcperms/interceptor_test.go new file mode 100644 index 000000000..1c014f6fb --- /dev/null +++ b/rpcperms/interceptor_test.go @@ -0,0 +1,129 @@ +package rpcperms + +import ( + "bytes" + "context" + "errors" + "testing" + + "github.com/btcsuite/btclog/v2" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// TestPanicRecoveryUnaryServerInterceptor asserts that unary handler panics are +// converted to internal RPC errors rather than propagating to the process. +func TestPanicRecoveryUnaryServerInterceptor(t *testing.T) { + interceptor := panicRecoveryUnaryServerInterceptor(btclog.Disabled) + info := &grpc.UnaryServerInfo{ + FullMethod: "/test.Service/Unary", + } + + resp, err := interceptor( + t.Context(), nil, info, + func(context.Context, any) (any, error) { + panic("boom") + }, + ) + require.Nil(t, resp) + require.Error(t, err) + require.Equal(t, codes.Internal, status.Code(err)) + + expectedResp := struct{}{} + expectedErr := errors.New("handler error") + resp, err = interceptor( + t.Context(), nil, info, + func(context.Context, any) (any, error) { + return expectedResp, expectedErr + }, + ) + require.Equal(t, expectedResp, resp) + require.ErrorIs(t, err, expectedErr) + + var nilLogger btclog.Logger + interceptor = panicRecoveryUnaryServerInterceptor(nilLogger) + resp, err = interceptor( + t.Context(), nil, info, + func(context.Context, any) (any, error) { + panic("boom") + }, + ) + require.Nil(t, resp) + require.Error(t, err) + require.Equal(t, codes.Internal, status.Code(err)) +} + +// TestPanicRecoveryStreamServerInterceptor asserts that stream handler panics +// are converted to internal RPC errors rather than propagating to the process. +func TestPanicRecoveryStreamServerInterceptor(t *testing.T) { + interceptor := panicRecoveryStreamServerInterceptor(btclog.Disabled) + info := &grpc.StreamServerInfo{ + FullMethod: "/test.Service/Stream", + } + + err := interceptor( + nil, nil, info, func(any, grpc.ServerStream) error { + panic("boom") + }, + ) + require.Error(t, err) + require.Equal(t, codes.Internal, status.Code(err)) + + expectedErr := errors.New("handler error") + err = interceptor( + nil, nil, info, func(any, grpc.ServerStream) error { + return expectedErr + }, + ) + require.ErrorIs(t, err, expectedErr) + + var nilLogger btclog.Logger + interceptor = panicRecoveryStreamServerInterceptor(nilLogger) + err = interceptor( + nil, nil, info, func(any, grpc.ServerStream) error { + panic("boom") + }, + ) + require.Error(t, err) + require.Equal(t, codes.Internal, status.Code(err)) + + var stream recordingServerStream + err = interceptor( + nil, &stream, info, func(_ any, ss grpc.ServerStream) error { + require.NoError(t, ss.SendMsg(struct{}{})) + panic("boom") + }, + ) + require.Error(t, err) + require.Equal(t, codes.Internal, status.Code(err)) + require.Equal(t, 1, stream.numSent) +} + +type recordingServerStream struct { + grpc.ServerStream + numSent int +} + +func (s *recordingServerStream) SendMsg(any) error { + s.numSent++ + return nil +} + +// TestTruncatePanicStack asserts that panic stack traces are capped with a +// readable truncation marker. +func TestTruncatePanicStack(t *testing.T) { + shortStack := []byte("short stack") + require.Equal(t, shortStack, truncatePanicStack(shortStack)) + + longStack := bytes.Repeat([]byte("stack frame\n"), maxPanicStackSize) + truncatedStack := truncatePanicStack(longStack) + + require.LessOrEqual(t, len(truncatedStack), maxPanicStackSize) + require.True( + t, bytes.HasSuffix( + truncatedStack, []byte(panicStackTruncatedMsg), + ), + ) +} From 2c4af78fe15b03899a5ce1f38d034d5a1919477d Mon Sep 17 00:00:00 2001 From: Erick Cestari Date: Fri, 19 Jun 2026 16:50:53 -0300 Subject: [PATCH 089/134] discovery: fix panic in DNS fallback SRV lookup The fallback SRV lookup type-asserted each DNS Answer record to *dns.SRV unconditionally. If the response contains a non-SRV record (e.g. an A or CNAME), the type assertion panics and crashes the daemon. Use the comma-ok form to skip non-SRV records instead. Also guard against an empty LookupHost result for the shim, which would otherwise panic on an out-of-bounds index into addrs. This is safe to discuss and fix in public. The bug is very unlikely to be exploitable: triggering it requires either a DNS seeder to serve a malformed response, or an on-path MITM injecting one (the fallback response is unauthenticated). A malicious seeder already has far more direct ways to disrupt a node, and a MITM attack is hard to mount, so the panic does not meaningfully widen the attack surface. (cherry picked from commit 2a3642c691e08c2115f9177251bb9cbeed5f0a6a) --- discovery/bootstrapper.go | 40 +++++- discovery/bootstrapper_test.go | 225 +++++++++++++++++++++++++++++++++ 2 files changed, 260 insertions(+), 5 deletions(-) create mode 100644 discovery/bootstrapper_test.go diff --git a/discovery/bootstrapper.go b/discovery/bootstrapper.go index 43e9d5ec2..0ccec568e 100644 --- a/discovery/bootstrapper.go +++ b/discovery/bootstrapper.go @@ -365,6 +365,11 @@ func (d *DNSSeedBootstrapper) fallBackSRVLookup(soaShim string, return nil, err } + if len(addrs) == 0 { + return nil, fmt.Errorf("no addresses for fallback DNS seed "+ + "shim %v", soaShim) + } + // Once we have the IP address, we'll establish a TCP connection using // port 53. dnsServer := net.JoinHostPort(addrs[0], "53") @@ -372,6 +377,7 @@ func (d *DNSSeedBootstrapper) fallBackSRVLookup(soaShim string, if err != nil { return nil, err } + _ = conn.SetDeadline(time.Now().Add(d.timeout)) dnsHost := fmt.Sprintf("_nodes._tcp.%v.", targetEndPoint) dnsConn := &dns.Conn{Conn: conn} @@ -399,7 +405,18 @@ func (d *DNSSeedBootstrapper) fallBackSRVLookup(soaShim string, // that net.LookupSRV would normally return. var rrs []*net.SRV for _, rr := range resp.Answer { - srv := rr.(*dns.SRV) + // The answer section may contain records other than SRV + // (e.g. A or CNAME), so use the comma-ok form to skip any + // non-SRV record instead of panicking on a failed type + // assertion. + srv, ok := rr.(*dns.SRV) + if !ok { + log.Infof("Skipping non-SRV record %T in fallback "+ + "DNS seed response for %v", rr, targetEndPoint) + + continue + } + rrs = append(rrs, &net.SRV{ Target: srv.Target, Port: srv.Port, @@ -408,6 +425,11 @@ func (d *DNSSeedBootstrapper) fallBackSRVLookup(soaShim string, }) } + if len(rrs) == 0 { + return nil, fmt.Errorf("no SRV records in fallback DNS seed "+ + "response for %v", targetEndPoint) + } + return rrs, nil } @@ -481,7 +503,9 @@ search: bechNodeHost := nodeSrv.Target addrs, err := d.net.LookupHost(bechNodeHost) if err != nil { - return nil, err + log.Tracef("Skipping node %v: %v", + bechNodeHost, err) + continue } if len(addrs) == 0 { @@ -506,7 +530,9 @@ search: bechNode := strings.Split(bechNodeHost, ".") _, nodeBytes5Bits, err := bech32.Decode(bechNode[0]) if err != nil { - return nil, err + log.Tracef("Skipping node %v: %v", + bechNodeHost, err) + continue } // Once we have the bech32 decoded pubkey, we'll need @@ -517,11 +543,15 @@ search: nodeBytes5Bits, 5, 8, false, ) if err != nil { - return nil, err + log.Tracef("Skipping node %v: %v", + bechNodeHost, err) + continue } nodeKey, err := btcec.ParsePubKey(nodeBytes) if err != nil { - return nil, err + log.Tracef("Skipping node %v: %v", + bechNodeHost, err) + continue } // If we have an ignore list, and this node is in the diff --git a/discovery/bootstrapper_test.go b/discovery/bootstrapper_test.go new file mode 100644 index 000000000..54104bc51 --- /dev/null +++ b/discovery/bootstrapper_test.go @@ -0,0 +1,225 @@ +package discovery + +import ( + "fmt" + "net" + "testing" + "time" + + "github.com/miekg/dns" + "github.com/stretchr/testify/require" +) + +// fallbackNet is a tor.Net stub used to drive fallBackSRVLookup. LookupHost +// returns shimAddrs and Dial serves a single DNS response, written by +// serveResp, over an in-memory pipe so the fallback path can be exercised +// without a real DNS server. +type fallbackNet struct { + shimAddrs []string + serveResp func(question *dns.Msg) *dns.Msg +} + +// Dial returns one end of an in-memory pipe and spins up a goroutine acting as +// the DNS server on the other end, which reads the SRV query and writes back +// the response produced by serveResp. +func (n *fallbackNet) Dial(_, _ string, + _ time.Duration) (net.Conn, error) { + + client, server := net.Pipe() + + // Act as the DNS server on the far end of the pipe: read the SRV + // query, then write back the crafted response. + go func() { + srvConn := &dns.Conn{Conn: server} + defer srvConn.Close() + + query, err := srvConn.ReadMsg() + if err != nil { + return + } + + _ = srvConn.WriteMsg(n.serveResp(query)) + }() + + return client, nil +} + +// LookupHost returns the configured shim addresses used to reach the fallback +// DNS server. +func (n *fallbackNet) LookupHost(_ string) ([]string, error) { + return n.shimAddrs, nil +} + +// LookupSRV is unsupported by this stub; the fallback path under test issues +// the SRV query manually over the Dial connection instead. +func (n *fallbackNet) LookupSRV(_, _, _ string, + _ time.Duration) (string, []*net.SRV, error) { + + return "", nil, fmt.Errorf("unsupported") +} + +// ResolveTCPAddr is unsupported by this stub as it is not exercised by the +// fallback SRV lookup path. +func (n *fallbackNet) ResolveTCPAddr(_, _ string) (*net.TCPAddr, error) { + return nil, fmt.Errorf("unsupported") +} + +// TestFallBackSRVLookupSkipsNonSRV ensures a DNS response whose Answer section +// contains non-SRV records (which an on-path attacker or malicious seed can +// inject, since the response is unauthenticated) is filtered rather than +// triggering a type-assertion panic that would crash the daemon. +func TestFallBackSRVLookupSkipsNonSRV(t *testing.T) { + t.Parallel() + + const target = "nodes.lightning.directory" + + srvTarget := "ln1qexample._nodes._tcp." + target + "." + + netStub := &fallbackNet{ + shimAddrs: []string{"127.0.0.1"}, + serveResp: func(q *dns.Msg) *dns.Msg { + resp := new(dns.Msg) + resp.SetReply(q) + resp.Rcode = dns.RcodeSuccess + + // A hostile/malformed Answer section: an A record and a + // CNAME interleaved with a single valid SRV record. + resp.Answer = []dns.RR{ + &dns.A{ + Hdr: dns.RR_Header{ + Name: q.Question[0].Name, + Rrtype: dns.TypeA, + }, + A: net.ParseIP("1.2.3.4"), + }, + &dns.CNAME{ + Hdr: dns.RR_Header{ + Name: q.Question[0].Name, + Rrtype: dns.TypeCNAME, + }, + Target: "evil.example.", + }, + &dns.SRV{ + Hdr: dns.RR_Header{ + Name: q.Question[0].Name, + Rrtype: dns.TypeSRV, + }, + Target: srvTarget, + Port: 9735, + }, + } + + return resp + }, + } + + bs := NewDNSSeedBootstrapper( + [][2]string{{target, "soa.lightning.directory"}}, + netStub, time.Second, + ) + d, ok := bs.(*DNSSeedBootstrapper) + require.True(t, ok) + + // The non-SRV records must be skipped, leaving only the valid SRV + // record. Crucially, this must not panic. + srvs, err := d.fallBackSRVLookup("soa.lightning.directory", target) + require.NoError(t, err) + require.Len(t, srvs, 1) + require.Equal(t, srvTarget, srvs[0].Target) +} + +// TestFallBackSRVLookupNoSRVRecords ensures a successful DNS response whose +// Answer section holds no SRV records (only CNAME/A entries, or is empty) +// returns an error rather than (nil, nil), so the caller does not mistake "no +// usable records" for a successful query. +func TestFallBackSRVLookupNoSRVRecords(t *testing.T) { + t.Parallel() + + const target = "nodes.lightning.directory" + + netStub := &fallbackNet{ + shimAddrs: []string{"127.0.0.1"}, + serveResp: func(q *dns.Msg) *dns.Msg { + resp := new(dns.Msg) + resp.SetReply(q) + resp.Rcode = dns.RcodeSuccess + + // Only a non-SRV record is present in the Answer + // section, leaving zero usable SRV targets. + resp.Answer = []dns.RR{ + &dns.A{ + Hdr: dns.RR_Header{ + Name: q.Question[0].Name, + Rrtype: dns.TypeA, + }, + A: net.ParseIP("1.2.3.4"), + }, + } + + return resp + }, + } + + bs := NewDNSSeedBootstrapper( + [][2]string{{target, "soa.lightning.directory"}}, + netStub, time.Second, + ) + d, ok := bs.(*DNSSeedBootstrapper) + require.True(t, ok) + + srvs, err := d.fallBackSRVLookup("soa.lightning.directory", target) + require.Error(t, err) + require.Empty(t, srvs) +} + +// TestFallBackSRVLookupEmptyAnswer ensures a successful DNS response with an +// entirely empty Answer section returns an error rather than (nil, nil), so the +// caller does not mistake an empty response for a successful query. +func TestFallBackSRVLookupEmptyAnswer(t *testing.T) { + t.Parallel() + + const target = "nodes.lightning.directory" + + netStub := &fallbackNet{ + shimAddrs: []string{"127.0.0.1"}, + serveResp: func(q *dns.Msg) *dns.Msg { + resp := new(dns.Msg) + resp.SetReply(q) + resp.Rcode = dns.RcodeSuccess + + // Leave the Answer section empty. + resp.Answer = nil + + return resp + }, + } + + bs := NewDNSSeedBootstrapper( + [][2]string{{target, "soa.lightning.directory"}}, + netStub, time.Second, + ) + d, ok := bs.(*DNSSeedBootstrapper) + require.True(t, ok) + + srvs, err := d.fallBackSRVLookup("soa.lightning.directory", target) + require.Error(t, err) + require.Empty(t, srvs) +} + +// TestFallBackSRVLookupNoShimAddrs ensures an empty LookupHost result for the +// fallback shim returns an error instead of panicking on an out-of-bounds +// index. +func TestFallBackSRVLookupNoShimAddrs(t *testing.T) { + t.Parallel() + + netStub := &fallbackNet{shimAddrs: nil} + + bs := NewDNSSeedBootstrapper(nil, netStub, time.Second) + d, ok := bs.(*DNSSeedBootstrapper) + require.True(t, ok) + + _, err := d.fallBackSRVLookup( + "soa.lightning.directory", "nodes.lightning.directory", + ) + require.Error(t, err) +} From 010f72681bea347faf4a59b23fdc3344a81c4bac Mon Sep 17 00:00:00 2001 From: Erick Cestari Date: Tue, 23 Jun 2026 14:52:25 -0300 Subject: [PATCH 090/134] docs: add release note for 0.20.2 (cherry picked from commit 2ee49698afa74373fa51acc61edd5620cb623f61) --- docs/release-notes/release-notes-0.20.2.md | 64 ++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 docs/release-notes/release-notes-0.20.2.md diff --git a/docs/release-notes/release-notes-0.20.2.md b/docs/release-notes/release-notes-0.20.2.md new file mode 100644 index 000000000..2b6d390ff --- /dev/null +++ b/docs/release-notes/release-notes-0.20.2.md @@ -0,0 +1,64 @@ +# Release Notes +- [Bug Fixes](#bug-fixes) +- [New Features](#new-features) + - [Functional Enhancements](#functional-enhancements) + - [RPC Additions](#rpc-additions) + - [lncli Additions](#lncli-additions) +- [Improvements](#improvements) + - [Functional Updates](#functional-updates) + - [RPC Updates](#rpc-updates) + - [lncli Updates](#lncli-updates) + - [Breaking Changes](#breaking-changes) + - [Performance Improvements](#performance-improvements) + - [Deprecations](#deprecations) +- [Technical and Architectural Updates](#technical-and-architectural-updates) + - [BOLT Spec Updates](#bolt-spec-updates) + - [Testing](#testing) + - [Database](#database) + - [Code Health](#code-health) + - [Tooling and Documentation](#tooling-and-documentation) +- [Contributors (Alphabetical Order)](#contributors) + +# Bug Fixes + +* [Fixed a panic](https://github.com/lightningnetwork/lnd/pull/10914) in the + DNS fallback SRV lookup, which unconditionally type-asserted each DNS Answer + record to `*dns.SRV` and crashed the daemon when the response contained a + non-SRV record. Non-SRV records are now skipped, and an empty `LookupHost` + result for the shim no longer triggers an out-of-bounds index. + +# New Features + +## Functional Enhancements + +## RPC Additions + +## lncli Additions + +# Improvements +## Functional Updates + +## RPC Updates + +## lncli Updates + +## Breaking Changes + +## Performance Improvements + +## Deprecations + +# Technical and Architectural Updates +## BOLT Spec Updates + +## Testing + +## Database + +## Code Health + +## Tooling and Documentation + +# Contributors (Alphabetical Order) + +* Erick Cestari From 6968e2bdabbf4af616d7b66040e61daf6abffd59 Mon Sep 17 00:00:00 2001 From: ziggie Date: Wed, 24 Jun 2026 09:36:20 -0300 Subject: [PATCH 091/134] htlcswitch+invoices: align final-hop CLTV handling Apply the same supported CLTV delta range to final-hop HTLC handling that is already used for forwarding. Use a shared helper for the exit-hop link path so final-hop amount and CLTV checks remain consistent across invoice creation and HTLC handling. (cherry picked from commit c5992d381ff22da6b9f059dd5873782ed6778d20) --- htlcswitch/hop/forwarding_info.go | 49 ++++++++ htlcswitch/hop/forwarding_info_test.go | 137 ++++++++++++++++++++++ htlcswitch/link.go | 61 +++++++--- htlcswitch/link_isolated_test.go | 32 +++++- htlcswitch/link_test.go | 153 ++++++++++++++++++++++++- invoices/invoices.go | 6 + lnrpc/invoicesrpc/addinvoice.go | 7 +- lnrpc/invoicesrpc/addinvoice_test.go | 19 +++ 8 files changed, 444 insertions(+), 20 deletions(-) create mode 100644 htlcswitch/hop/forwarding_info_test.go diff --git a/htlcswitch/hop/forwarding_info.go b/htlcswitch/hop/forwarding_info.go index 92ea541cc..4f325f84e 100644 --- a/htlcswitch/hop/forwarding_info.go +++ b/htlcswitch/hop/forwarding_info.go @@ -34,3 +34,52 @@ type ForwardingInfo struct { // correct context. PathID *chainhash.Hash } + +// FinalHtlcValidationResult describes the result of checking a final-hop +// HTLC against the onion payload and supported final-hop CLTV range. +type FinalHtlcValidationResult uint8 + +const ( + // FinalHtlcValid indicates that the HTLC matches the final-hop payload + // and supported final-hop CLTV range. + FinalHtlcValid FinalHtlcValidationResult = iota + + // FinalHtlcInvalidAmount indicates that the HTLC amount is below the + // final amount requested by the onion payload. + FinalHtlcInvalidAmount + + // FinalHtlcInvalidCltv indicates that the HTLC expiry is below the + // final CLTV requested by the onion payload. + FinalHtlcInvalidCltv + + // FinalHtlcExpiryTooFar indicates that the HTLC expiry is outside the + // supported final-hop CLTV range. + FinalHtlcExpiryTooFar +) + +// ValidateFinalHtlc checks final-hop HTLC amount and CLTV details before +// invoice resolution. +func ValidateFinalHtlc(amt lnwire.MilliSatoshi, expiry, heightNow, + maxFinalCltvDelta uint32, fwdInfo ForwardingInfo, + validateAmount bool) FinalHtlcValidationResult { + + switch { + // The HTLC amount is below the final amount requested by the + // onion payload. + case validateAmount && amt < fwdInfo.AmountToForward: + return FinalHtlcInvalidAmount + + // The HTLC expiry is below the final CLTV requested by the onion + // payload. + case expiry < fwdInfo.OutgoingCTLV: + return FinalHtlcInvalidCltv + + // The HTLC expiry is outside the supported final-hop CLTV range. + case expiry > heightNow && expiry-heightNow > maxFinalCltvDelta: + + return FinalHtlcExpiryTooFar + + default: + return FinalHtlcValid + } +} diff --git a/htlcswitch/hop/forwarding_info_test.go b/htlcswitch/hop/forwarding_info_test.go new file mode 100644 index 000000000..68ac6f2a5 --- /dev/null +++ b/htlcswitch/hop/forwarding_info_test.go @@ -0,0 +1,137 @@ +package hop + +import ( + "testing" + + "github.com/lightningnetwork/lnd/lnwire" + "github.com/stretchr/testify/require" +) + +// TestValidateFinalHtlc exercises the final-hop HTLC validation helper. +func TestValidateFinalHtlc(t *testing.T) { + t.Parallel() + + const ( + amount = lnwire.MilliSatoshi(1000) + expiry = uint32(150) + height = uint32(100) + maxCltvDelta = uint32(50) + ) + + fwdInfo := ForwardingInfo{ + AmountToForward: amount, + OutgoingCTLV: expiry, + NextHop: Exit, + } + + testCases := []struct { + name string + amount lnwire.MilliSatoshi + expiry uint32 + height uint32 + maxCltvDelta uint32 + fwdInfo ForwardingInfo + validateAmount bool + expected FinalHtlcValidationResult + }{{ + name: "valid", + amount: amount, + expiry: expiry, + height: height + 1, + maxCltvDelta: maxCltvDelta, + fwdInfo: fwdInfo, + validateAmount: true, + expected: FinalHtlcValid, + }, { + name: "amount too low", + amount: amount - 1, + expiry: expiry, + height: height, + maxCltvDelta: maxCltvDelta, + fwdInfo: fwdInfo, + validateAmount: true, + expected: FinalHtlcInvalidAmount, + }, { + name: "amount check disabled", + amount: amount - 1, + expiry: expiry, + height: height, + maxCltvDelta: maxCltvDelta, + fwdInfo: fwdInfo, + validateAmount: false, + expected: FinalHtlcValid, + }, { + name: "final cltv too low", + amount: amount, + expiry: expiry - 1, + height: height, + maxCltvDelta: maxCltvDelta, + fwdInfo: fwdInfo, + validateAmount: true, + expected: FinalHtlcInvalidCltv, + }, { + name: "expiry too far", + amount: amount, + expiry: expiry + 1, + height: height, + maxCltvDelta: maxCltvDelta, + fwdInfo: fwdInfo, + validateAmount: true, + expected: FinalHtlcExpiryTooFar, + }, { + name: "expiry at maximum", + amount: amount, + expiry: expiry, + height: height, + maxCltvDelta: maxCltvDelta, + fwdInfo: fwdInfo, + validateAmount: true, + expected: FinalHtlcValid, + }, { + name: "height above expiry", + amount: amount, + expiry: expiry, + height: expiry + 1, + maxCltvDelta: maxCltvDelta, + fwdInfo: fwdInfo, + validateAmount: true, + expected: FinalHtlcValid, + }, { + name: "amount failure takes precedence", + amount: amount - 1, + expiry: expiry - 1, + height: height, + maxCltvDelta: maxCltvDelta, + fwdInfo: fwdInfo, + validateAmount: true, + expected: FinalHtlcInvalidAmount, + }, { + name: "cltv failure takes precedence over " + + "expiry too far", + amount: amount, + expiry: expiry + maxCltvDelta + 1, + height: height, + maxCltvDelta: maxCltvDelta, + fwdInfo: ForwardingInfo{ + AmountToForward: amount, + OutgoingCTLV: expiry + maxCltvDelta + 2, + NextHop: Exit, + }, + validateAmount: true, + expected: FinalHtlcInvalidCltv, + }} + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + result := ValidateFinalHtlc( + testCase.amount, testCase.expiry, + testCase.height, testCase.maxCltvDelta, + testCase.fwdInfo, testCase.validateAmount, + ) + + require.Equal(t, testCase.expected, result) + }) + } +} diff --git a/htlcswitch/link.go b/htlcswitch/link.go index 4c81964cc..3f5685bb7 100644 --- a/htlcswitch/link.go +++ b/htlcswitch/link.go @@ -2517,13 +2517,17 @@ func (l *channelLink) CheckHtlcForward(payHash [32]byte, incomingHtlcAmt, // Finally, we'll ensure that the time-lock on the outgoing HTLC meets // the following constraint: the incoming time-lock minus our time-lock - // delta should equal the outgoing time lock. Otherwise, whether the + // delta should equal the outgoing time lock. Otherwise, either the // sender messed up, or an intermediate node tampered with the HTLC. timeDelta := policy.TimeLockDelta - if incomingTimeout < outgoingTimeout+timeDelta { + var incomingDelta uint32 + if incomingTimeout >= outgoingTimeout { + incomingDelta = incomingTimeout - outgoingTimeout + } + if incomingTimeout < outgoingTimeout || incomingDelta < timeDelta { l.log.Warnf("incoming htlc(%x) has incorrect time-lock value: "+ "expected at least %v block delta, got %v block delta", - payHash[:], timeDelta, incomingTimeout-outgoingTimeout) + payHash[:], timeDelta, incomingDelta) // Grab the latest routing policy so the sending node is up to // date with our current policy. @@ -2536,6 +2540,17 @@ func (l *channelLink) CheckHtlcForward(payHash [32]byte, incomingHtlcAmt, return NewLinkError(failure) } + // Check that the incoming to outgoing time-lock delta is within the + // configured CLTV range. + if incomingDelta > l.cfg.MaxOutgoingCltvExpiry { + l.log.Warnf("incoming htlc(%x) has a time-lock delta "+ + "outside the configured CLTV range: got %v, "+ + "but maximum is %v", + payHash[:], incomingDelta, l.cfg.MaxOutgoingCltvExpiry) + + return NewLinkError(&lnwire.FailExpiryTooFar{}) + } + return nil } @@ -3131,7 +3146,7 @@ func (l *channelLink) processRemoteAdds(fwdPkg *channeldb.FwdPkg) { obfuscator, false, ) - l.log.Error("rejected htlc that uses use as an " + + l.log.Error("rejected htlc that uses us as an " + "introduction point when we do not support " + "route blinding") @@ -3406,11 +3421,16 @@ func (l *channelLink) processExitHop(add lnwire.UpdateAddHTLC, }, ) + switch hop.ValidateFinalHtlc( + add.Amount, add.Expiry, heightNow, + invoices.MaxFinalCltvDelta, + fwdInfo, !isCustomHTLC, + ) { // As we're the exit hop, we'll double check the hop-payload included in // the HTLC to ensure that it was crafted correctly by the sender and // is compatible with the HTLC we were extended. If an external // validator is active we might bypass the amount check. - if !isCustomHTLC && add.Amount < fwdInfo.AmountToForward { + case hop.FinalHtlcInvalidAmount: l.log.Errorf("onion payload of incoming htlc(%x) has "+ "incompatible value: expected <=%v, got %v", add.PaymentHash, add.Amount, fwdInfo.AmountToForward) @@ -3421,11 +3441,10 @@ func (l *channelLink) processExitHop(add lnwire.UpdateAddHTLC, l.sendHTLCError(add, sourceRef, failure, obfuscator, true) return nil - } // We'll also ensure that our time-lock value has been computed // correctly. - if add.Expiry < fwdInfo.OutgoingCTLV { + case hop.FinalHtlcInvalidCltv: l.log.Errorf("onion payload of incoming htlc(%x) has "+ "incompatible time-lock: expected <=%v, got %v", add.PaymentHash, add.Expiry, fwdInfo.OutgoingCTLV) @@ -3436,6 +3455,22 @@ func (l *channelLink) processExitHop(add lnwire.UpdateAddHTLC, l.sendHTLCError(add, sourceRef, failure, obfuscator, true) + return nil + + // Check that the incoming HTLC expiry is within the supported final-hop + // CLTV range. + case hop.FinalHtlcExpiryTooFar: + l.log.Warnf("incoming htlc(%x) has a final-hop CLTV delta "+ + "outside the supported range: got %v, but maximum "+ + "is %v", + add.PaymentHash, add.Expiry-heightNow, + invoices.MaxFinalCltvDelta) + + failure := NewLinkError( + lnwire.NewFailIncorrectDetails(add.Amount, heightNow), + ) + l.sendHTLCError(add, sourceRef, failure, obfuscator, true) + return nil } @@ -3547,8 +3582,8 @@ func (l *channelLink) forwardBatch(replay bool, packets ...*htlcPacket) { } } -// sendHTLCError functions cancels HTLC and send cancel message back to the -// peer from which HTLC was received. +// sendHTLCError cancels the HTLC and sends a cancel message back to the peer +// from which the HTLC was received. func (l *channelLink) sendHTLCError(add lnwire.UpdateAddHTLC, sourceRef channeldb.AddRef, failure *LinkError, e hop.ErrorEncrypter, isReceive bool) { @@ -3561,7 +3596,7 @@ func (l *channelLink) sendHTLCError(add lnwire.UpdateAddHTLC, err = l.channel.FailHTLC(add.ID, reason, &sourceRef, nil, nil) if err != nil { - l.log.Errorf("unable cancel htlc: %v", err) + l.log.Errorf("unable to cancel htlc: %v", err) return } @@ -4259,7 +4294,7 @@ func (l *channelLink) processRemoteCommitSig(ctx context.Context, // want to ensure we release that memory back to the runtime. l.uncommittedPreimages = nil - // We just received a new updates to our local commitment chain, + // We just received new updates to our local commitment chain, // validate this new commitment, closing the link if invalid. auxSigBlob, err := msg.CustomRecords.Serialize() if err != nil { @@ -4587,7 +4622,7 @@ func (l *channelLink) processLocalUpdateFulfillHTLC(ctx context.Context, } // An HTLC we forward to the switch has just settled somewhere upstream. - // Therefore we settle the HTLC within the our local state machine. + // Therefore we settle the HTLC within our local state machine. inKey := pkt.inKey() err := l.channel.SettleHTLC( htlc.PaymentPreimage, pkt.incomingHTLCID, pkt.sourceRef, @@ -4654,7 +4689,7 @@ func (l *channelLink) processLocalUpdateFailHTLC(ctx context.Context, } // An HTLC cancellation has been triggered somewhere upstream, we'll - // remove then HTLC from our local state machine. + // remove the HTLC from our local state machine. inKey := pkt.inKey() err := l.channel.FailHTLC( pkt.incomingHTLCID, htlc.Reason, pkt.sourceRef, pkt.destRef, diff --git a/htlcswitch/link_isolated_test.go b/htlcswitch/link_isolated_test.go index 9e74c4875..323153ec0 100644 --- a/htlcswitch/link_isolated_test.go +++ b/htlcswitch/link_isolated_test.go @@ -237,11 +237,37 @@ func (l *linkTestContext) sendSettleBobToAlice(htlcID uint64, l.aliceLink.HandleChannelUpdate(settle) } -// receiveSettleAliceToBob waits for Alice to send a HTLC settle message to -// Bob, then hands this to Bob. +// receiveFailAliceToBob waits for Alice to fail an HTLC to Bob. func (l *linkTestContext) receiveFailAliceToBob() { l.t.Helper() + l.receiveFailAliceToBobMsg() +} + +// receiveFailAliceToBobWithCode waits for Alice to fail an HTLC to Bob and +// verifies that the failure code matches the expectation. +func (l *linkTestContext) receiveFailAliceToBobWithCode( + code lnwire.FailCode) { + + l.t.Helper() + + failMsg := l.receiveFailAliceToBobMsg() + failure, err := newMockDeobfuscator().DecryptError(failMsg.Reason) + if err != nil { + l.t.Fatalf("unable to decrypt failure: %v", err) + } + + if failure.WireMessage().Code() != code { + l.t.Fatalf("expected %v but got %v", + code, failure.WireMessage().Code()) + } +} + +// receiveFailAliceToBobMsg waits for Alice to send a fail HTLC message to Bob, +// applies it to Bob, and returns the message. +func (l *linkTestContext) receiveFailAliceToBobMsg() *lnwire.UpdateFailHTLC { + l.t.Helper() + var msg lnwire.Message select { case msg = <-l.aliceMsgs: @@ -258,6 +284,8 @@ func (l *linkTestContext) receiveFailAliceToBob() { if err != nil { l.t.Fatalf("unable to apply received fail htlc: %v", err) } + + return failMsg } // assertNoMsgFromAlice asserts that Alice hasn't sent a message. Before diff --git a/htlcswitch/link_test.go b/htlcswitch/link_test.go index 101a47b98..cb3292b0f 100644 --- a/htlcswitch/link_test.go +++ b/htlcswitch/link_test.go @@ -6320,17 +6320,51 @@ func TestCheckHtlcForward(t *testing.T) { }) - t.Run("cltv expiry too far in the future", func(t *testing.T) { - // Check that expiry isn't too far in the future. + t.Run("cltv expiry outside supported range", func(t *testing.T) { + // Check that expiry stays within the supported range. result := link.CheckHtlcForward( hash, 1500, 1000, 10200, 10100, models.InboundFee{}, 0, lnwire.ShortChannelID{}, nil, ) + _, ok := result.WireMessage().(*lnwire.FailExpiryTooFar) + if !ok { + t.Fatalf("expected FailExpiryTooFar failure code") + } + }) + + t.Run("incoming cltv delta outside range", func(t *testing.T) { + result := link.CheckHtlcForward( + hash, 1500, 1000, 150+DefaultMaxOutgoingCltvExpiry+1, + 150, models.InboundFee{}, 0, lnwire.ShortChannelID{}, + nil, + ) if _, ok := result.WireMessage().(*lnwire.FailExpiryTooFar); !ok { t.Fatalf("expected FailExpiryTooFar failure code") } }) + t.Run("incoming cltv delta at maximum", func(t *testing.T) { + result := link.CheckHtlcForward( + hash, 1500, 1000, 150+DefaultMaxOutgoingCltvExpiry, + 150, models.InboundFee{}, 0, lnwire.ShortChannelID{}, + nil, + ) + require.Nil(t, result) + }) + + t.Run("incoming cltv below outgoing cltv", func(t *testing.T) { + result := link.CheckHtlcForward( + hash, 1500, 1000, 190, 200, models.InboundFee{}, 0, + lnwire.ShortChannelID{}, nil, + ) + _, ok := result.WireMessage().(*lnwire.FailIncorrectCltvExpiry) + if !ok { + t.Fatalf( + "expected FailIncorrectCltvExpiry failure code", + ) + } + }) + t.Run("inbound fee satisfied", func(t *testing.T) { t.Parallel() @@ -6662,6 +6696,121 @@ func TestChannelLinkHoldInvoiceRestart(t *testing.T) { } } +// TestChannelLinkExitHopExpiryTooFar asserts that an exit hop fails an +// incoming HTLC if its expiry is outside the supported range. +func TestChannelLinkExitHopExpiryTooFar(t *testing.T) { + t.Parallel() + + const chanAmt = btcutil.SatoshiPerBitcoin * 5 + harness, err := newSingleLinkTestHarness(t, chanAmt, 0) + require.NoError(t, err, "unable to create link") + + if err := harness.start(); err != nil { + t.Fatalf("unable to start test harness: %v", err) + } + t.Cleanup(harness.aliceLink.Stop) + + coreLink, ok := harness.aliceLink.(*channelLink) + require.True(t, ok) + + registry, ok := coreLink.cfg.Registry.(*mockInvoiceRegistry) + require.True(t, ok) + + alicePeer, ok := coreLink.cfg.Peer.(*mockPeer) + require.True(t, ok) + aliceMsgs := alicePeer.sentMsgs + + registry.settleChan = make(chan lntypes.Hash) + + htlc, invoice := generateHtlcAndInvoice(t, 0) + htlc.Expiry = testStartingHeight + + invpkg.MaxFinalCltvDelta + 1 + + err = registry.AddInvoice(t.Context(), *invoice, htlc.PaymentHash) + require.NoError(t, err, "unable to add invoice to registry") + + ctx := linkTestContext{ + t: t, + aliceSwitch: harness.aliceSwitch, + aliceLink: harness.aliceLink, + aliceMsgs: aliceMsgs, + bobChannel: harness.bobChannel, + } + + ctx.sendHtlcBobToAlice(htlc) + ctx.sendCommitSigBobToAlice(1) + ctx.receiveRevAndAckAliceToBob() + ctx.receiveCommitSigAliceToBob(1) + ctx.sendRevAndAckBobToAlice() + ctx.receiveFailAliceToBobWithCode( + lnwire.CodeIncorrectOrUnknownPaymentDetails, + ) + ctx.receiveCommitSigAliceToBob(0) + + select { + case <-registry.settleChan: + t.Fatal("exit hop notification received") + case <-time.After(time.Second): + } +} + +// TestChannelLinkExitHopExpiryAtMaximum asserts that an exit hop accepts an +// incoming HTLC if its expiry is exactly at the maximum. +func TestChannelLinkExitHopExpiryAtMaximum(t *testing.T) { + t.Parallel() + + const chanAmt = btcutil.SatoshiPerBitcoin * 5 + harness, err := newSingleLinkTestHarness(t, chanAmt, 0) + require.NoError(t, err, "unable to create link") + + if err := harness.start(); err != nil { + t.Fatalf("unable to start test harness: %v", err) + } + t.Cleanup(harness.aliceLink.Stop) + + coreLink, ok := harness.aliceLink.(*channelLink) + require.True(t, ok) + + registry, ok := coreLink.cfg.Registry.(*mockInvoiceRegistry) + require.True(t, ok) + + alicePeer, ok := coreLink.cfg.Peer.(*mockPeer) + require.True(t, ok) + aliceMsgs := alicePeer.sentMsgs + + registry.settleChan = make(chan lntypes.Hash) + + htlc, invoice := generateHtlcAndInvoice(t, 0) + htlc.Expiry = testStartingHeight + + invpkg.MaxFinalCltvDelta + + err = registry.AddInvoice(t.Context(), *invoice, htlc.PaymentHash) + require.NoError(t, err, "unable to add invoice to registry") + + ctx := linkTestContext{ + t: t, + aliceSwitch: harness.aliceSwitch, + aliceLink: harness.aliceLink, + aliceMsgs: aliceMsgs, + bobChannel: harness.bobChannel, + } + + ctx.sendHtlcBobToAlice(htlc) + ctx.sendCommitSigBobToAlice(1) + ctx.receiveRevAndAckAliceToBob() + ctx.receiveCommitSigAliceToBob(1) + ctx.sendRevAndAckBobToAlice() + + select { + case <-registry.settleChan: + case <-time.After(5 * time.Second): + t.Fatal("expected exit hop notification") + } + + ctx.receiveSettleAliceToBob() + ctx.receiveCommitSigAliceToBob(0) +} + // TestChannelLinkRevocationWindowRegular asserts that htlcs paying to a regular // invoice are settled even if the revocation window gets exhausted. func TestChannelLinkRevocationWindowRegular(t *testing.T) { diff --git a/invoices/invoices.go b/invoices/invoices.go index d6d59b4a0..0df3fe6f2 100644 --- a/invoices/invoices.go +++ b/invoices/invoices.go @@ -3,6 +3,7 @@ package invoices import ( "errors" "fmt" + "math" "strings" "time" @@ -22,6 +23,11 @@ const ( // TODO(halseth): determine the max length payment request when field // lengths are final. MaxPaymentRequestSize = 4096 + + // MaxFinalCltvDelta is the upper bound for final CLTV deltas used by + // invoice creation and final-hop HTLC validation. It matches + // routing.MaxCLTVDelta. + MaxFinalCltvDelta = math.MaxUint16 ) var ( diff --git a/lnrpc/invoicesrpc/addinvoice.go b/lnrpc/invoicesrpc/addinvoice.go index aba5da4df..a7d8af655 100644 --- a/lnrpc/invoicesrpc/addinvoice.go +++ b/lnrpc/invoicesrpc/addinvoice.go @@ -6,7 +6,6 @@ import ( "crypto/rand" "errors" "fmt" - "math" mathRand "math/rand" "sort" "time" @@ -405,10 +404,12 @@ func AddInvoice(ctx context.Context, cfg *AddInvoiceConfig, options = append(options, zpay32.Description(invoice.Memo)) } - if invoice.CltvExpiry > routing.MaxCLTVDelta { + // Final-hop invoices are limited to the same CLTV bound used by the + // link and contractcourt validation. + if invoice.CltvExpiry > invoices.MaxFinalCltvDelta { return nil, nil, fmt.Errorf("CLTV delta of %v is too large, "+ "max accepted is: %v", invoice.CltvExpiry, - math.MaxUint16) + invoices.MaxFinalCltvDelta) } // We'll use our current default CLTV value unless one was specified as diff --git a/lnrpc/invoicesrpc/addinvoice_test.go b/lnrpc/invoicesrpc/addinvoice_test.go index 9394ce26d..1a6b8997e 100644 --- a/lnrpc/invoicesrpc/addinvoice_test.go +++ b/lnrpc/invoicesrpc/addinvoice_test.go @@ -9,6 +9,7 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/lightningnetwork/lnd/channeldb" "github.com/lightningnetwork/lnd/graph/db/models" + "github.com/lightningnetwork/lnd/invoices" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/zpay32" "github.com/stretchr/testify/mock" @@ -25,6 +26,24 @@ var ( pubkey = btcec.NewPublicKey(new(btcec.FieldVal).SetInt(4), pubKeyY) ) +// TestAddInvoiceRejectsCltvAboveMaxIncoming asserts that invoice creation +// rejects final CLTV deltas above the supported maximum. +func TestAddInvoiceRejectsCltvAboveMaxIncoming(t *testing.T) { + t.Parallel() + + _, _, err := AddInvoice( + t.Context(), &AddInvoiceConfig{}, &AddInvoiceData{ + CltvExpiry: invoices.MaxFinalCltvDelta + 1, + }, + ) + require.ErrorContains( + t, err, fmt.Sprintf( + "max accepted is: %v", + invoices.MaxFinalCltvDelta, + ), + ) +} + type hopHintsConfigMock struct { t *testing.T mock.Mock From 141acda471386e52e1839cffe48c290ed1fa46cb Mon Sep 17 00:00:00 2001 From: ziggie Date: Wed, 24 Jun 2026 09:36:26 -0300 Subject: [PATCH 092/134] config: check cltv expiry policy range Check configured and advertised forwarding CLTV deltas against max-cltv-expiry so local configuration and advertised channel policy stay within the same supported range. (cherry picked from commit b8e861fe6b17a9fcd1c93ac8e0caba732e19ade9) --- config.go | 58 +++++++++++++++++++++++++++++++++++++++++++++++ config_test.go | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++ rpcserver.go | 12 ++++------ 3 files changed, 123 insertions(+), 8 deletions(-) diff --git a/config.go b/config.go index 6d5bc546a..65454e6b7 100644 --- a/config.go +++ b/config.go @@ -259,6 +259,58 @@ const ( defaultNoDisconnectOnPongFailure = false ) +// validateMaxOutgoingCltvExpiry validates the configured maximum outgoing CLTV +// expiry against the node's default time lock delta. +func validateMaxOutgoingCltvExpiry(maxCltvExpiry, timeLockDelta uint32) error { + if maxCltvExpiry < timeLockDelta { + return fmt.Errorf( + "max-cltv-expiry must be at least %v", timeLockDelta, + ) + } + + if maxCltvExpiry > MaxTimeLockDelta { + return fmt.Errorf( + "max-cltv-expiry must be at most %v", MaxTimeLockDelta, + ) + } + + return nil +} + +// validateCltvDeltaBounds validates a CLTV delta against LND's absolute +// supported bounds. +func validateCltvDeltaBounds(delta uint32) error { + if delta < minTimeLockDelta { + return fmt.Errorf("time lock delta of %v is too small, "+ + "minimum supported is %v", delta, minTimeLockDelta) + } + + if delta > MaxTimeLockDelta { + return fmt.Errorf("time lock delta of %v is too big, "+ + "maximum supported is %v", delta, MaxTimeLockDelta) + } + + return nil +} + +// validateChannelPolicyTimeLockDelta validates an advertised channel policy +// time lock delta against the node's supported forwarding bounds. +func validateChannelPolicyTimeLockDelta(timeLockDelta, + maxOutgoingCltvExpiry uint32) error { + + if err := validateCltvDeltaBounds(timeLockDelta); err != nil { + return err + } + + if timeLockDelta > maxOutgoingCltvExpiry { + return fmt.Errorf("time lock delta of %v exceeds "+ + "max-cltv-expiry of %v", timeLockDelta, + maxOutgoingCltvExpiry) + } + + return nil +} + var ( // DefaultLndDir is the default directory where lnd tries to find its // configuration file and store its data. This is a directory in the @@ -1113,6 +1165,12 @@ func ValidateConfig(cfg Config, interceptor signal.Interceptor, fileParser, cfg.MaxCommitFeeRateAnchors) } + if err := validateMaxOutgoingCltvExpiry( + cfg.MaxOutgoingCltvExpiry, cfg.Bitcoin.TimeLockDelta, + ); err != nil { + return nil, mkErr("%v", err) + } + // Validate the Tor config parameters. socks, err := lncfg.ParseAddressString( cfg.Tor.SOCKS, strconv.Itoa(defaultTorSOCKSPort), diff --git a/config_test.go b/config_test.go index 765580749..2136068b5 100644 --- a/config_test.go +++ b/config_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/lightningnetwork/lnd/chainreg" + "github.com/lightningnetwork/lnd/htlcswitch" "github.com/lightningnetwork/lnd/routing" "github.com/stretchr/testify/require" ) @@ -117,3 +118,63 @@ func TestSupplyEnvValue(t *testing.T) { }) } } + +// TestValidateMaxOutgoingCltvExpiry asserts that max-cltv-expiry accepts +// values within its supported bounds and rejects values outside them. +func TestValidateMaxOutgoingCltvExpiry(t *testing.T) { + t.Parallel() + + cfg := DefaultConfig() + + require.NoError( + t, validateMaxOutgoingCltvExpiry( + htlcswitch.DefaultMaxOutgoingCltvExpiry, + cfg.Bitcoin.TimeLockDelta, + ), + ) + require.NoError(t, validateMaxOutgoingCltvExpiry( + MaxTimeLockDelta, MaxTimeLockDelta, + )) + + err := validateMaxOutgoingCltvExpiry( + cfg.Bitcoin.TimeLockDelta-1, + cfg.Bitcoin.TimeLockDelta, + ) + require.ErrorContains(t, err, "max-cltv-expiry must be at least") + + err = validateMaxOutgoingCltvExpiry( + MaxTimeLockDelta+1, cfg.Bitcoin.TimeLockDelta, + ) + require.ErrorContains(t, err, "max-cltv-expiry must be at most") +} + +// TestValidateChannelPolicyTimeLockDelta asserts that advertised channel +// policy CLTV deltas stay within the node's supported forwarding bounds. +func TestValidateChannelPolicyTimeLockDelta(t *testing.T) { + t.Parallel() + + cfg := DefaultConfig() + + require.NoError(t, validateChannelPolicyTimeLockDelta( + cfg.Bitcoin.TimeLockDelta, cfg.MaxOutgoingCltvExpiry, + )) + require.NoError(t, validateChannelPolicyTimeLockDelta( + cfg.MaxOutgoingCltvExpiry, cfg.MaxOutgoingCltvExpiry, + )) + + err := validateChannelPolicyTimeLockDelta( + minTimeLockDelta-1, cfg.MaxOutgoingCltvExpiry, + ) + require.ErrorContains(t, err, "time lock delta of") + require.ErrorContains(t, err, "is too small") + + err = validateChannelPolicyTimeLockDelta( + MaxTimeLockDelta+1, MaxTimeLockDelta, + ) + require.ErrorContains(t, err, "is too big") + + err = validateChannelPolicyTimeLockDelta( + cfg.MaxOutgoingCltvExpiry+1, cfg.MaxOutgoingCltvExpiry, + ) + require.ErrorContains(t, err, "exceeds max-cltv-expiry") +} diff --git a/rpcserver.go b/rpcserver.go index 64eb40fb8..886b61813 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -8078,14 +8078,10 @@ func (r *rpcServer) UpdateChannelPolicy(ctx context.Context, // We'll also ensure that the user isn't setting a CLTV delta that // won't give outgoing HTLCs enough time to fully resolve if needed. - if req.TimeLockDelta < minTimeLockDelta { - return nil, fmt.Errorf("time lock delta of %v is too small, "+ - "minimum supported is %v", req.TimeLockDelta, - minTimeLockDelta) - } else if req.TimeLockDelta > uint32(MaxTimeLockDelta) { - return nil, fmt.Errorf("time lock delta of %v is too big, "+ - "maximum supported is %v", req.TimeLockDelta, - MaxTimeLockDelta) + if err := validateChannelPolicyTimeLockDelta( + req.TimeLockDelta, r.cfg.MaxOutgoingCltvExpiry, + ); err != nil { + return nil, err } // By default, positive inbound fees are rejected. From 1c82b7a27dffde3daa9969458d2d13574c3f04f6 Mon Sep 17 00:00:00 2001 From: ziggie Date: Wed, 24 Jun 2026 09:36:33 -0300 Subject: [PATCH 093/134] contractcourt: align final htlc handling in contest resolver Mirror the link's final-hop HTLC checks in the incoming contest resolver so the off-chain link path and on-chain resolver use the same final-hop handling. Use MaxFinalCltvDelta directly in contractcourt to match invoice creation and link processing. Preserve the link's custom HTLC behavior by leaving amount checks to auxiliary traffic shapers when custom HTLC handling applies. (cherry picked from commit 03ca45a282fb122230259744696df7d0bcb1fbe3) --- contractcourt/chain_arbitrator.go | 5 + .../htlc_incoming_contest_resolver.go | 74 ++++++++++- .../htlc_incoming_contest_resolver_test.go | 118 +++++++++++++++++- contractcourt/interfaces.go | 9 ++ contractcourt/mock_registry_test.go | 4 + server.go | 5 + 6 files changed, 207 insertions(+), 8 deletions(-) diff --git a/contractcourt/chain_arbitrator.go b/contractcourt/chain_arbitrator.go index 05eb46a68..72b95e5cd 100644 --- a/contractcourt/chain_arbitrator.go +++ b/contractcourt/chain_arbitrator.go @@ -75,6 +75,11 @@ type ChainArbitratorConfig struct { // htlcs. This value can be lower than the incoming broadcast delta. OutgoingBroadcastDelta uint32 + // CustomHtlcChecker optionally identifies HTLCs that should bypass the + // standard final-hop amount check because their amount validation is + // handled by auxiliary channel logic. + CustomHtlcChecker fn.Option[CustomHtlcChecker] + // NewSweepAddr is a function that returns a new address under control // by the wallet. We'll use this to sweep any no-delay outputs as a // result of unilateral channel closes. diff --git a/contractcourt/htlc_incoming_contest_resolver.go b/contractcourt/htlc_incoming_contest_resolver.go index 95d08c417..388e3cb97 100644 --- a/contractcourt/htlc_incoming_contest_resolver.go +++ b/contractcourt/htlc_incoming_contest_resolver.go @@ -78,6 +78,31 @@ func (h *htlcIncomingContestResolver) processFinalHtlcFail() error { return nil } +// invalidFinalHtlc returns true if the HTLC is an exit-hop HTLC that fails +// final-hop validation. +func (h *htlcIncomingContestResolver) invalidFinalHtlc( + payload *hop.Payload, height uint32) bool { + + if payload.FwdInfo.NextHop != hop.Exit { + return false + } + + // Custom HTLCs still enforce final CLTV correctness, but leave amount + // validation to auxiliary channel logic. + validateAmount := !fn.MapOptionZ( + h.CustomHtlcChecker, + func(checker CustomHtlcChecker) bool { + return checker.IsCustomHTLC(h.htlc.CustomRecords) + }, + ) + + return hop.ValidateFinalHtlc( + h.htlc.Amt, h.htlcExpiry, height, + invoices.MaxFinalCltvDelta, payload.FwdInfo, + validateAmount, + ) != hop.FinalHtlcValid +} + // Launch will call the inner resolver's launch method if the preimage can be // found, otherwise it's a no-op. func (h *htlcIncomingContestResolver) Launch() error { @@ -101,7 +126,7 @@ func (h *htlcIncomingContestResolver) Launch() error { return nil } - h.log.Debugf("found preimage for htlc=%x, transforming into success "+ + h.log.Debugf("found preimage for htlc=%x, transforming into success "+ "resolver and launching it", h.htlc.RHash) // Once we've applied the preimage, we'll launch the inner resolver to @@ -177,6 +202,32 @@ func (h *htlcIncomingContestResolver) Resolve() (ContractResolver, error) { log.Debugf("%T(%v): Resolving incoming HTLC(expiry=%v, height=%v)", h, h.htlcResolution.ClaimOutpoint, h.htlcExpiry, currentHeight) + // If this final-hop HTLC does not match the expected final-hop details, + // keep the on-chain path aligned with link-level handling by recording + // a failed final outcome and leaving timeout resolution to the remote + // party. + if h.invalidFinalHtlc(payload, uint32(currentHeight)) { + log.Infof("%T(%v): final-hop HTLC did not match expected "+ + "details (amt=%v, expected_amt=%v, expiry=%v, "+ + "expected_expiry=%v, height=%v, max=%v), resolving as "+ + "failed", h, h.htlcResolution.ClaimOutpoint, + h.htlc.Amt, payload.FwdInfo.AmountToForward, + h.htlcExpiry, payload.FwdInfo.OutgoingCTLV, + currentHeight, invoices.MaxFinalCltvDelta) + h.markResolved() + + if err := h.processFinalHtlcFail(); err != nil { + return nil, err + } + + report := h.report().resolverReport( + nil, channeldb.ResolverTypeIncomingHtlc, + channeldb.ResolverOutcomeAbandoned, + ) + + return nil, h.Checkpoint(h, report) + } + // We'll first check if this HTLC has been timed out, if so, we can // return now and mark ourselves as resolved. If we're past the point of // expiry of the HTLC, then at this point the sender can sweep it, so @@ -615,11 +666,21 @@ var _ htlcContractResolver = (*htlcIncomingContestResolver)(nil) // NOTE: Since we have two places to query the preimage, we need to check both // the preimage db and the invoice db to look up the preimage. func (h *htlcIncomingContestResolver) findAndapplyPreimage() (bool, error) { + // Decode the hop payload up front; both the known-preimage path and the + // registry lookup below rely on the decoded final-hop details. + payload, _, err := h.decodePayload() + // Query to see if we already know the preimage. preimage, ok := h.PreimageDB.LookupPreimage(h.htlc.RHash) // If the preimage is known, we'll apply it. if ok { + if err == nil && + h.invalidFinalHtlc(payload, h.broadcastHeight) { + + return false, nil + } + if err := h.applyPreimage(preimage); err != nil { return false, err } @@ -628,8 +689,7 @@ func (h *htlcIncomingContestResolver) findAndapplyPreimage() (bool, error) { return true, nil } - // First try to parse the payload. - payload, _, err := h.decodePayload() + // Without a preimage we need a valid payload to look up the invoice. if err != nil { h.log.Errorf("Cannot decode payload of htlc %v", h.HtlcPoint()) @@ -639,11 +699,17 @@ func (h *htlcIncomingContestResolver) findAndapplyPreimage() (bool, error) { } // Exit early if this is not the exit hop, which means we are not the - // payment receiver and don't have preimage. + // payment receiver and don't have the preimage. if payload.FwdInfo.NextHop != hop.Exit { return false, nil } + // If this final-hop HTLC does not match the expected final-hop details, + // let Resolve record the failed final outcome. + if h.invalidFinalHtlc(payload, h.broadcastHeight) { + return false, nil + } + // Notify registry that we are potentially resolving as an exit hop // on-chain. If this HTLC indeed pays to an existing invoice, the // invoice registry will tell us what to do with the HTLC. This is diff --git a/contractcourt/htlc_incoming_contest_resolver_test.go b/contractcourt/htlc_incoming_contest_resolver_test.go index f17190e96..d8ec533da 100644 --- a/contractcourt/htlc_incoming_contest_resolver_test.go +++ b/contractcourt/htlc_incoming_contest_resolver_test.go @@ -9,6 +9,7 @@ import ( sphinx "github.com/lightningnetwork/lightning-onion" "github.com/lightningnetwork/lnd/chainntnfs" "github.com/lightningnetwork/lnd/channeldb" + "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/graph/db/models" "github.com/lightningnetwork/lnd/htlcswitch/hop" "github.com/lightningnetwork/lnd/input" @@ -260,8 +261,95 @@ func TestHtlcIncomingResolverExitCancelHodl(t *testing.T) { ctx.waitForResult(false) } +// TestHtlcIncomingResolverInvalidFinalHtlc asserts that an exit-hop HTLC with +// final-hop details outside the expected range resolves without querying the +// invoice registry for a preimage. +func TestHtlcIncomingResolverInvalidFinalHtlc(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + cachePreimage bool + mutate func(*incomingResolverTestContext) + }{{ + name: "expiry too far", + mutate: func(ctx *incomingResolverTestContext) { + ctx.resolver.htlcExpiry = testInitialBlockHeight + + invoices.MaxFinalCltvDelta + 1 + }, + }, { + name: "cached preimage expiry too far", + cachePreimage: true, + mutate: func(ctx *incomingResolverTestContext) { + ctx.resolver.htlcExpiry = testInitialBlockHeight + + invoices.MaxFinalCltvDelta + 1 + }, + }, { + name: "amount too low", + mutate: func(ctx *incomingResolverTestContext) { + ctx.onionProcessor.forwardAmount = testHtlcAmount + 1 + }, + }, { + name: "final cltv too low", + mutate: func(ctx *incomingResolverTestContext) { + ctx.onionProcessor.outgoingCltv = testHtlcExpiry + 1 + }, + }} + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + defer timeout()() + + ctx := newIncomingResolverTestContext(t, true) + if testCase.cachePreimage { + ctx.witnessBeacon.lookupPreimage[testResHash] = + testResPreimage + } + + testCase.mutate(ctx) + resolution := invoices.NewSettleResolution( + testResPreimage, testResCircuitKey, + testAcceptHeight, invoices.ResultSettled, + ) + ctx.registry.notifyResolution = resolution + + ctx.resolve() + ctx.waitForResult(false) + + require.EqualValues( + t, 0, ctx.registry.notifyCalls.Load(), + ) + }) + } +} + +// TestHtlcIncomingResolverCustomHtlc asserts that a custom HTLC bypasses the +// standard final-hop amount check in contract court, matching the link flow. +func TestHtlcIncomingResolverCustomHtlc(t *testing.T) { + t.Parallel() + defer timeout()() + + ctx := newIncomingResolverTestContext(t, true) + ctx.resolver.CustomHtlcChecker = fn.Some[CustomHtlcChecker]( + mockCustomHtlcChecker{}, + ) + ctx.onionProcessor.forwardAmount = testHtlcAmount + 1 + ctx.registry.notifyResolution = invoices.NewSettleResolution( + testResPreimage, testResCircuitKey, testAcceptHeight, + invoices.ResultSettled, + ) + + ctx.resolve() + ctx.waitForResult(true) + + require.NotZero(t, ctx.registry.notifyCalls.Load()) +} + type mockHopIterator struct { - isExit bool + isExit bool + forwardAmount int + outgoingCltv uint32 hop.Iterator } @@ -271,11 +359,21 @@ func (h *mockHopIterator) HopPayload() (*hop.Payload, hop.RouteRole, error) { nextAddress = [8]byte{0x01} } + forwardAmount := h.forwardAmount + if forwardAmount == 0 { + forwardAmount = 100 + } + + outgoingCltv := h.outgoingCltv + if outgoingCltv == 0 { + outgoingCltv = 40 + } + return hop.NewLegacyPayload(&sphinx.HopData{ Realm: [1]byte{}, NextAddress: nextAddress, - ForwardAmount: 100, - OutgoingCltv: 40, + ForwardAmount: uint64(forwardAmount), + OutgoingCltv: outgoingCltv, ExtraBytes: [12]byte{}, }), hop.RouteRoleCleartext, nil } @@ -286,6 +384,8 @@ func (h *mockHopIterator) EncodeNextHop(w io.Writer) error { type mockOnionProcessor struct { isExit bool + forwardAmount int + outgoingCltv uint32 offeredOnionBlob []byte } @@ -298,7 +398,17 @@ func (o *mockOnionProcessor) ReconstructHopIterator(r io.Reader, rHash []byte, } o.offeredOnionBlob = data - return &mockHopIterator{isExit: o.isExit}, nil + return &mockHopIterator{ + isExit: o.isExit, + forwardAmount: o.forwardAmount, + outgoingCltv: o.outgoingCltv, + }, nil +} + +type mockCustomHtlcChecker struct{} + +func (m mockCustomHtlcChecker) IsCustomHTLC(lnwire.CustomRecords) bool { + return true } type incomingResolverTestContext struct { diff --git a/contractcourt/interfaces.go b/contractcourt/interfaces.go index 75b81e9dd..e5e55fcfa 100644 --- a/contractcourt/interfaces.go +++ b/contractcourt/interfaces.go @@ -37,6 +37,15 @@ type Registry interface { HodlUnsubscribeAll(subscriber chan<- interface{}) } +// CustomHtlcChecker identifies HTLCs whose final-hop amount validation is +// handled by auxiliary channel logic instead of the standard onion amount +// field. +type CustomHtlcChecker interface { + // IsCustomHTLC returns true if the HTLC carries custom records that + // make it subject to auxiliary HTLC handling. + IsCustomHTLC(htlcRecords lnwire.CustomRecords) bool +} + // OnionProcessor is an interface used to decode onion blobs. type OnionProcessor interface { // ReconstructHopIterator attempts to decode a valid sphinx packet from diff --git a/contractcourt/mock_registry_test.go b/contractcourt/mock_registry_test.go index 0530ab51d..9dd0dea69 100644 --- a/contractcourt/mock_registry_test.go +++ b/contractcourt/mock_registry_test.go @@ -2,6 +2,7 @@ package contractcourt import ( "context" + "sync/atomic" "github.com/lightningnetwork/lnd/graph/db/models" "github.com/lightningnetwork/lnd/invoices" @@ -21,6 +22,7 @@ type mockRegistry struct { notifyChan chan notifyExitHopData notifyErr error notifyResolution invoices.HtlcResolution + notifyCalls atomic.Int32 } func (r *mockRegistry) NotifyExitHopHtlc(payHash lntypes.Hash, @@ -29,6 +31,8 @@ func (r *mockRegistry) NotifyExitHopHtlc(payHash lntypes.Hash, wireCustomRecords lnwire.CustomRecords, payload invoices.Payload) (invoices.HtlcResolution, error) { + r.notifyCalls.Add(1) + // Exit early if the notification channel is nil. if hodlChan == nil { return r.notifyResolution, r.notifyErr diff --git a/server.go b/server.go index 3f38e8855..f0b4fec3b 100644 --- a/server.go +++ b/server.go @@ -1246,6 +1246,11 @@ func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr, ChainHash: *s.cfg.ActiveNetParams.GenesisHash, IncomingBroadcastDelta: lncfg.DefaultIncomingBroadcastDelta, OutgoingBroadcastDelta: lncfg.DefaultOutgoingBroadcastDelta, + CustomHtlcChecker: fn.MapOption( + func(t htlcswitch.AuxTrafficShaper) contractcourt.CustomHtlcChecker { + return t + }, + )(s.implCfg.TrafficShaper), NewSweepAddr: func() ([]byte, error) { addr, err := newSweepPkScriptGen( cc.Wallet, netParams, From 30ddd606fca7f04014ef3a2f37697acdc91593b7 Mon Sep 17 00:00:00 2001 From: ziggie Date: Wed, 24 Jun 2026 11:16:59 -0300 Subject: [PATCH 094/134] htlcswitch: fix outgoing cltv spelling Rename the ForwardingInfo.OutgoingCTLV field to OutgoingCLTV and update all call sites. This keeps the exported field spelling consistent with the CLTV terminology used elsewhere. Also fix the remaining CTLV typos in nearby comments. (cherry picked from commit 4518bc72763261a640276fb898562b0b3f4257cb) --- contractcourt/channel_arbitrator.go | 2 +- contractcourt/htlc_incoming_contest_resolver.go | 2 +- graph/db/models/channel.go | 2 +- htlcswitch/hop/forwarding_info.go | 6 +++--- htlcswitch/hop/forwarding_info_test.go | 4 ++-- htlcswitch/hop/fuzz_test.go | 2 +- htlcswitch/hop/iterator.go | 2 +- htlcswitch/hop/iterator_test.go | 2 +- htlcswitch/hop/payload.go | 4 ++-- htlcswitch/link.go | 10 +++++----- htlcswitch/link_test.go | 8 ++++---- htlcswitch/mock.go | 8 +++++--- htlcswitch/switch_test.go | 2 +- witness_beacon.go | 2 +- 14 files changed, 29 insertions(+), 27 deletions(-) diff --git a/contractcourt/channel_arbitrator.go b/contractcourt/channel_arbitrator.go index ae2ffc8ab..1c4db6fb8 100644 --- a/contractcourt/channel_arbitrator.go +++ b/contractcourt/channel_arbitrator.go @@ -1437,7 +1437,7 @@ func (c *ChannelArbitrator) sweepAnchors(anchors *lnwallet.AnchorResolutions, // HTLCs, or, // - half of the least CLTV from incoming HTLCs if the preimage is available. // -// We use half of the CTLV value to ensure that we have enough time to sweep +// We use half of the CLTV value to ensure that we have enough time to sweep // the second-level HTLCs. // // It also finds the total value that are time-sensitive, which is the sum of diff --git a/contractcourt/htlc_incoming_contest_resolver.go b/contractcourt/htlc_incoming_contest_resolver.go index 388e3cb97..d075166c1 100644 --- a/contractcourt/htlc_incoming_contest_resolver.go +++ b/contractcourt/htlc_incoming_contest_resolver.go @@ -212,7 +212,7 @@ func (h *htlcIncomingContestResolver) Resolve() (ContractResolver, error) { "expected_expiry=%v, height=%v, max=%v), resolving as "+ "failed", h, h.htlcResolution.ClaimOutpoint, h.htlc.Amt, payload.FwdInfo.AmountToForward, - h.htlcExpiry, payload.FwdInfo.OutgoingCTLV, + h.htlcExpiry, payload.FwdInfo.OutgoingCLTV, currentHeight, invoices.MaxFinalCltvDelta) h.markResolved() diff --git a/graph/db/models/channel.go b/graph/db/models/channel.go index 2069d1629..abe4c3be7 100644 --- a/graph/db/models/channel.go +++ b/graph/db/models/channel.go @@ -123,7 +123,7 @@ type ForwardingPolicy struct { // create the time-lock value for the forwarded outgoing HTLC. The // following constraint MUST hold for an HTLC to be forwarded: // - // * incomingHtlc.timeLock - timeLockDelta = fwdInfo.OutgoingCTLV + // * incomingHtlc.timeLock - timeLockDelta = fwdInfo.OutgoingCLTV // // where fwdInfo is the forwarding information extracted from the // per-hop payload of the incoming HTLC's onion packet. diff --git a/htlcswitch/hop/forwarding_info.go b/htlcswitch/hop/forwarding_info.go index 4f325f84e..539e0db1f 100644 --- a/htlcswitch/hop/forwarding_info.go +++ b/htlcswitch/hop/forwarding_info.go @@ -20,9 +20,9 @@ type ForwardingInfo struct { // node should forward to the next hop. AmountToForward lnwire.MilliSatoshi - // OutgoingCTLV is the specified value of the CTLV timelock to be used + // OutgoingCLTV is the specified value of the CLTV timelock to be used // in the outgoing HTLC. - OutgoingCTLV uint32 + OutgoingCLTV uint32 // NextBlinding is an optional blinding point to be passed to the next // node in UpdateAddHtlc. This field is set if the htlc is part of a @@ -71,7 +71,7 @@ func ValidateFinalHtlc(amt lnwire.MilliSatoshi, expiry, heightNow, // The HTLC expiry is below the final CLTV requested by the onion // payload. - case expiry < fwdInfo.OutgoingCTLV: + case expiry < fwdInfo.OutgoingCLTV: return FinalHtlcInvalidCltv // The HTLC expiry is outside the supported final-hop CLTV range. diff --git a/htlcswitch/hop/forwarding_info_test.go b/htlcswitch/hop/forwarding_info_test.go index 68ac6f2a5..82a5ad0c6 100644 --- a/htlcswitch/hop/forwarding_info_test.go +++ b/htlcswitch/hop/forwarding_info_test.go @@ -20,7 +20,7 @@ func TestValidateFinalHtlc(t *testing.T) { fwdInfo := ForwardingInfo{ AmountToForward: amount, - OutgoingCTLV: expiry, + OutgoingCLTV: expiry, NextHop: Exit, } @@ -114,7 +114,7 @@ func TestValidateFinalHtlc(t *testing.T) { maxCltvDelta: maxCltvDelta, fwdInfo: ForwardingInfo{ AmountToForward: amount, - OutgoingCTLV: expiry + maxCltvDelta + 2, + OutgoingCLTV: expiry + maxCltvDelta + 2, NextHop: Exit, }, validateAmount: true, diff --git a/htlcswitch/hop/fuzz_test.go b/htlcswitch/hop/fuzz_test.go index e5c00b525..525194c38 100644 --- a/htlcswitch/hop/fuzz_test.go +++ b/htlcswitch/hop/fuzz_test.go @@ -84,7 +84,7 @@ func FuzzOnionPacket(f *testing.F) { func hopFromPayload(p *Payload) (*route.Hop, uint64) { return &route.Hop{ AmtToForward: p.FwdInfo.AmountToForward, - OutgoingTimeLock: p.FwdInfo.OutgoingCTLV, + OutgoingTimeLock: p.FwdInfo.OutgoingCLTV, MPP: p.MPP, AMP: p.AMP, Metadata: p.metadata, diff --git a/htlcswitch/hop/iterator.go b/htlcswitch/hop/iterator.go index 553c4921d..cf04b88a1 100644 --- a/htlcswitch/hop/iterator.go +++ b/htlcswitch/hop/iterator.go @@ -327,7 +327,7 @@ func deriveBlindedRouteForwardingInfo(r *sphinxHopIterator, payload.FwdInfo = ForwardingInfo{ NextHop: nextSCID.Val, AmountToForward: fwdAmt, - OutgoingCTLV: r.blindingKit.IncomingCltv - uint32( + OutgoingCLTV: r.blindingKit.IncomingCltv - uint32( relayInfo.Val.CltvExpiryDelta, ), // Remap from blinding override type to blinding point type. diff --git a/htlcswitch/hop/iterator_test.go b/htlcswitch/hop/iterator_test.go index ab435a986..b132a046d 100644 --- a/htlcswitch/hop/iterator_test.go +++ b/htlcswitch/hop/iterator_test.go @@ -35,7 +35,7 @@ func TestSphinxHopIteratorForwardingInstructions(t *testing.T) { expectedFwdInfo := ForwardingInfo{ NextHop: lnwire.NewShortChanIDFromInt(nextAddrInt), AmountToForward: lnwire.MilliSatoshi(hopData.ForwardAmount), - OutgoingCTLV: hopData.OutgoingCltv, + OutgoingCLTV: hopData.OutgoingCltv, } // For our TLV payload, we'll serialize the hop into into a TLV stream diff --git a/htlcswitch/hop/payload.go b/htlcswitch/hop/payload.go index fc456828a..14a0813e8 100644 --- a/htlcswitch/hop/payload.go +++ b/htlcswitch/hop/payload.go @@ -128,7 +128,7 @@ func NewLegacyPayload(f *sphinx.HopData) *Payload { FwdInfo: ForwardingInfo{ NextHop: lnwire.NewShortChanIDFromInt(nextHop), AmountToForward: lnwire.MilliSatoshi(f.ForwardAmount), - OutgoingCTLV: f.OutgoingCltv, + OutgoingCLTV: f.OutgoingCltv, }, customRecords: make(record.CustomSet), } @@ -203,7 +203,7 @@ func ParseTLVPayload(r io.Reader) (*Payload, map[tlv.Type][]byte, error) { FwdInfo: ForwardingInfo{ NextHop: lnwire.NewShortChanIDFromInt(cid), AmountToForward: lnwire.MilliSatoshi(amt), - OutgoingCTLV: cltv, + OutgoingCLTV: cltv, }, MPP: mpp, AMP: amp, diff --git a/htlcswitch/link.go b/htlcswitch/link.go index 3f5685bb7..056403cb3 100644 --- a/htlcswitch/link.go +++ b/htlcswitch/link.go @@ -3200,7 +3200,7 @@ func (l *channelLink) processRemoteAdds(fwdPkg *channeldb.FwdPkg) { // Otherwise, it was already processed, we can // can collect it and continue. outgoingAdd := &lnwire.UpdateAddHTLC{ - Expiry: fwdInfo.OutgoingCTLV, + Expiry: fwdInfo.OutgoingCLTV, Amount: fwdInfo.AmountToForward, PaymentHash: add.PaymentHash, BlindingPoint: fwdInfo.NextBlinding, @@ -3239,7 +3239,7 @@ func (l *channelLink) processRemoteAdds(fwdPkg *channeldb.FwdPkg) { htlc: outgoingAdd, obfuscator: obfuscator, incomingTimeout: add.Expiry, - outgoingTimeout: fwdInfo.OutgoingCTLV, + outgoingTimeout: fwdInfo.OutgoingCLTV, inOnionCustomRecords: pld.CustomRecords(), inboundFee: inboundFee, inWireCustomRecords: add.CustomRecords.Copy(), @@ -3258,7 +3258,7 @@ func (l *channelLink) processRemoteAdds(fwdPkg *channeldb.FwdPkg) { // create the outgoing HTLC using the parameters as // specified in the forwarding info. addMsg := &lnwire.UpdateAddHTLC{ - Expiry: fwdInfo.OutgoingCTLV, + Expiry: fwdInfo.OutgoingCLTV, Amount: fwdInfo.AmountToForward, PaymentHash: add.PaymentHash, BlindingPoint: fwdInfo.NextBlinding, @@ -3316,7 +3316,7 @@ func (l *channelLink) processRemoteAdds(fwdPkg *channeldb.FwdPkg) { htlc: addMsg, obfuscator: obfuscator, incomingTimeout: add.Expiry, - outgoingTimeout: fwdInfo.OutgoingCTLV, + outgoingTimeout: fwdInfo.OutgoingCLTV, inOnionCustomRecords: pld.CustomRecords(), inboundFee: inboundFee, inWireCustomRecords: add.CustomRecords.Copy(), @@ -3447,7 +3447,7 @@ func (l *channelLink) processExitHop(add lnwire.UpdateAddHTLC, case hop.FinalHtlcInvalidCltv: l.log.Errorf("onion payload of incoming htlc(%x) has "+ "incompatible time-lock: expected <=%v, got %v", - add.PaymentHash, add.Expiry, fwdInfo.OutgoingCTLV) + add.PaymentHash, add.Expiry, fwdInfo.OutgoingCLTV) failure := NewLinkError( lnwire.NewFinalIncorrectCltvExpiry(add.Expiry), diff --git a/htlcswitch/link_test.go b/htlcswitch/link_test.go index cb3292b0f..a64942d5c 100644 --- a/htlcswitch/link_test.go +++ b/htlcswitch/link_test.go @@ -779,13 +779,13 @@ func testChannelLinkInboundFee(t *testing.T, //nolint:thelper NextHop: n.carolChannelLink. ShortChanID(), AmountToForward: 1_000_000, - OutgoingCTLV: 106, + OutgoingCLTV: 106, }, }, { FwdInfo: hop.ForwardingInfo{ AmountToForward: 1_000_000, - OutgoingCTLV: 106, + OutgoingCLTV: 106, }, }, } @@ -974,7 +974,7 @@ func TestExitNodeHTLCTimelockExceedsPayload(t *testing.T) { // The proper value of the outgoing CLTV should be the policy set by // the receiving node, instead we set it to be a value less than the // incoming HTLC timelock. - hops[0].FwdInfo.OutgoingCTLV = htlcExpiry - 1 + hops[0].FwdInfo.OutgoingCLTV = htlcExpiry - 1 firstHop := n.firstBobChannelLink.ShortChanID() _, err = makePayment( n.aliceServer, n.bobServer, firstHop, hops, amount, htlcAmt, @@ -1012,7 +1012,7 @@ func TestExitNodeTimelockPayloadExceedsHTLC(t *testing.T) { // The proper value of the outgoing CLTV should be the policy set by // the receiving node, instead we set it to be a value greater than the // incoming HTLC timelock. - hops[0].FwdInfo.OutgoingCTLV = htlcExpiry + 1 + hops[0].FwdInfo.OutgoingCLTV = htlcExpiry + 1 firstHop := n.firstBobChannelLink.ShortChanID() _, err = makePayment( n.aliceServer, n.bobServer, firstHop, hops, amount, htlcAmt, diff --git a/htlcswitch/mock.go b/htlcswitch/mock.go index 70bd73c37..dbab96727 100644 --- a/htlcswitch/mock.go +++ b/htlcswitch/mock.go @@ -375,7 +375,8 @@ func encodeFwdInfo(w io.Writer, f *hop.ForwardingInfo) error { return err } - if err := binary.Write(w, binary.BigEndian, f.OutgoingCTLV); err != nil { + err := binary.Write(w, binary.BigEndian, f.OutgoingCLTV) + if err != nil { return err } @@ -514,7 +515,7 @@ func (p *mockIteratorDecoder) DecodeHopIterator(r io.Reader, rHash []byte, Realm: [1]byte{}, // hop.BitcoinNetwork NextAddress: nextHopBytes, ForwardAmount: uint64(f.AmountToForward), - OutgoingCltv: f.OutgoingCTLV, + OutgoingCltv: f.OutgoingCLTV, }) } @@ -569,7 +570,8 @@ func decodeFwdInfo(r io.Reader, f *hop.ForwardingInfo) error { return err } - if err := binary.Read(r, binary.BigEndian, &f.OutgoingCTLV); err != nil { + err := binary.Read(r, binary.BigEndian, &f.OutgoingCLTV) + if err != nil { return err } diff --git a/htlcswitch/switch_test.go b/htlcswitch/switch_test.go index e8176aaeb..884e9f368 100644 --- a/htlcswitch/switch_test.go +++ b/htlcswitch/switch_test.go @@ -3603,7 +3603,7 @@ func getThreeHopEvents(channels *clusterChannels, htlcID uint64, bobInfo := HtlcInfo{ IncomingTimeLock: htlc.Expiry, IncomingAmt: htlc.Amount, - OutgoingTimeLock: hops[1].FwdInfo.OutgoingCTLV, + OutgoingTimeLock: hops[1].FwdInfo.OutgoingCLTV, OutgoingAmt: hops[1].FwdInfo.AmountToForward, } diff --git a/witness_beacon.go b/witness_beacon.go index 6c315d0c1..eba0bcad6 100644 --- a/witness_beacon.go +++ b/witness_beacon.go @@ -102,7 +102,7 @@ func (p *preimageBeacon) SubscribeUpdates( HtlcID: htlc.HtlcIndex, }, OutgoingChanID: payload.FwdInfo.NextHop, - OutgoingExpiry: payload.FwdInfo.OutgoingCTLV, + OutgoingExpiry: payload.FwdInfo.OutgoingCLTV, OutgoingAmount: payload.FwdInfo.AmountToForward, InOnionCustomRecords: payload.CustomRecords(), InWireCustomRecords: htlc.CustomRecords, From b9619a28cdb7123fc0342e8f022cab440ade28b6 Mon Sep 17 00:00:00 2001 From: ziggie Date: Fri, 26 Jun 2026 07:34:39 -0300 Subject: [PATCH 095/134] docs: update release notes (cherry picked from commit bffae65a60477b671a0873a6629bc5a3f6db9bd3) --- docs/release-notes/release-notes-0.20.2.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/release-notes/release-notes-0.20.2.md b/docs/release-notes/release-notes-0.20.2.md index 2b6d390ff..e8dd2e318 100644 --- a/docs/release-notes/release-notes-0.20.2.md +++ b/docs/release-notes/release-notes-0.20.2.md @@ -38,6 +38,17 @@ # Improvements ## Functional Updates +* lnd now [validates the CLTV expiry of HTLCs at the final + hop](https://github.com/lightningnetwork/lnd/pull/10927). A final HTLC whose + CLTV expiry falls outside the node's receive policy is failed back, bringing + the final hop in line with the CLTV delta limits already enforced on the + forwarding path. + As part of this change, the channel policy `TimeLockDelta` is now validated + against LND's supported forwarding bounds: any node that previously set a + per-channel `TimeLockDelta` greater than `2016` (the maximum default value) + will now have its `UpdateChannelPolicy` request rejected, and must lower the + value accordingly below the specified maximum. + ## RPC Updates ## lncli Updates @@ -62,3 +73,4 @@ # Contributors (Alphabetical Order) * Erick Cestari +* Ziggie From de8ca73610f4e8136e7fe61eb240f8083db11cfd Mon Sep 17 00:00:00 2001 From: ziggie Date: Mon, 22 Jun 2026 08:17:05 -0300 Subject: [PATCH 096/134] itest: cover on-chain interceptor settlement Add coverage for held forwards that move on chain after the incoming channel force closes. The restart case exercises the path where Bob loses the in-memory held set and contractcourt re-offers the HTLC through the witness beacon. The no-restart case keeps the original off-chain hold and proves that settlement must still reach the on-chain resolver. (cherry picked from commit 9b31ba83ef52e2d2800f53564f44b9efd4a75ca3) --- itest/list_on_test.go | 8 ++ itest/lnd_forward_interceptor_test.go | 166 ++++++++++++++++++++++++++ 2 files changed, 174 insertions(+) diff --git a/itest/list_on_test.go b/itest/list_on_test.go index 92c6547b3..02fd01218 100644 --- a/itest/list_on_test.go +++ b/itest/list_on_test.go @@ -443,6 +443,14 @@ var allTestCases = []*lntest.TestCase{ Name: "forward interceptor restart", TestFunc: testForwardInterceptorRestart, }, + { + Name: "forward interceptor on chain settle after restart", + TestFunc: testForwardInterceptorOnChainSettleAfterRestart, + }, + { + Name: "forward interceptor on chain settle no restart", + TestFunc: testForwardInterceptorOnChainSettleNoRestart, + }, { Name: "invoice HTLC modifier basic", TestFunc: testInvoiceHtlcModifierBasic, diff --git a/itest/lnd_forward_interceptor_test.go b/itest/lnd_forward_interceptor_test.go index fc9a3f904..d1b7b3d48 100644 --- a/itest/lnd_forward_interceptor_test.go +++ b/itest/lnd_forward_interceptor_test.go @@ -505,6 +505,172 @@ func testForwardInterceptorRestart(ht *lntest.HarnessTest) { ) } +// testForwardInterceptorOnChainSettleAfterRestart tests that an HTLC offered +// to the interceptor by the on-chain resolver remains settleable after a new +// block is mined. This reproduces the incident path where Bob restarted after +// the force-close, so only the on-chain interceptor entry exists. +func testForwardInterceptorOnChainSettleAfterRestart(ht *lntest.HarnessTest) { + const ( + chanAmt = btcutil.Amount(300000) + invoiceAmt = int64(100000) + ) + + // Bob requires an interceptor so the forwarded HTLC remains held until + // the test explicitly resolves it. + p := lntest.OpenChannelParams{Amt: chanAmt} + cfgs := [][]string{nil, {"--requireinterceptor"}, nil} + chanPoints, nodes := ht.CreateSimpleNetwork(cfgs, p) + alice, bob, carol := nodes[0], nodes[1], nodes[2] + cpAB := chanPoints[0] + + // Fund Bob so he can publish the on-chain HTLC success sweep once the + // interceptor supplies the preimage. + ht.FundCoins(btcutil.SatoshiPerBitcoin, bob) + + interceptor, cancelInterceptor := bob.RPC.HtlcInterceptor() + + addResp := carol.RPC.AddInvoice(&lnrpc.Invoice{ + Value: invoiceAmt, + }) + invoice := carol.RPC.LookupInvoice(addResp.RHash) + + payHash, err := lntypes.MakeHash(invoice.RHash) + require.NoError(ht, err) + + req := &routerrpc.SendPaymentRequest{ + PaymentRequest: invoice.PaymentRequest, + FeeLimitMsat: noFeeLimitMsat, + } + ht.SendPaymentAssertInflight(alice, req) + + _ = ht.ReceiveHtlcInterceptor(interceptor) + ht.AssertIncomingHTLCActive(bob, cpAB, invoice.RHash) + ht.AssertPaymentStatus(alice, payHash, lnrpc.Payment_IN_FLIGHT) + + closeStream, _ := ht.CloseChannelAssertPending( + alice, cpAB, true, + ) + ht.AssertStreamChannelForceClosed( + alice, cpAB, false, closeStream, + ) + ht.AssertChannelPendingForceClose(bob, cpAB) + + cancelInterceptor() + ht.RestartNode(bob) + + // Re-register the interceptor after restart. The previous stream was + // cancelled before Bob went down. The incoming contest resolver only + // re-offers the on-chain HTLC to the active stream. + interceptor, cancelInterceptor = bob.RPC.HtlcInterceptor() + defer cancelInterceptor() + + // After restart, the incoming contest resolver re-offers the HTLC to + // the interceptor through the on-chain path. + intercepted := ht.ReceiveHtlcInterceptor(interceptor) + + // Mine one block after the on-chain intercept has been offered. With + // the current bug, the held entry is evicted here because the on-chain + // packet has no auto-fail height. + ht.MineEmptyBlocks(1) + + ht.AssertNumTxsInMempool(0) + err = interceptor.Send(&routerrpc.ForwardHtlcInterceptResponse{ + IncomingCircuitKey: intercepted.IncomingCircuitKey, + Action: routerrpc.ResolveHoldForwardAction_SETTLE, + Preimage: invoice.RPreimage, + }) + require.NoError(ht, err, "failed to settle intercepted HTLC") + + // The preimage should reach the contest resolver and register Bob's + // HTLC success input with the sweeper. + ht.AssertAtLeastNumPendingSweeps(bob, 1) + + // Give the sweeper another blockbeat to publish the sweep transaction. + ht.MineEmptyBlocks(1) + + ht.MineBlocksAndAssertNumTxes(1, 1) + ht.AssertPaymentStatus(alice, payHash, lnrpc.Payment_SUCCEEDED) + + // Bob's sweep is mined above. Clean up Alice's force close so the next + // test starts with an empty mempool. + ht.CleanupForceClose(alice) +} + +// testForwardInterceptorOnChainSettleNoRestart tests that an HTLC which was +// first held off-chain can still be settled after the incoming channel +// force-closes without restarting Bob. This covers the duplicate-entry path: +// the old off-chain held entry must not prevent settlement from reaching the +// on-chain contest resolver. +func testForwardInterceptorOnChainSettleNoRestart(ht *lntest.HarnessTest) { + const ( + chanAmt = btcutil.Amount(300000) + invoiceAmt = int64(100000) + ) + + // Bob requires an interceptor so the forwarded HTLC remains held until + // the test explicitly resolves it. + p := lntest.OpenChannelParams{Amt: chanAmt} + cfgs := [][]string{nil, {"--requireinterceptor"}, nil} + chanPoints, nodes := ht.CreateSimpleNetwork(cfgs, p) + alice, bob, carol := nodes[0], nodes[1], nodes[2] + cpAB := chanPoints[0] + + // Fund Bob so he can publish the on-chain HTLC success sweep once the + // interceptor supplies the preimage. + ht.FundCoins(btcutil.SatoshiPerBitcoin, bob) + + interceptor, cancelInterceptor := bob.RPC.HtlcInterceptor() + defer cancelInterceptor() + + addResp := carol.RPC.AddInvoice(&lnrpc.Invoice{ + Value: invoiceAmt, + }) + invoice := carol.RPC.LookupInvoice(addResp.RHash) + + payHash, err := lntypes.MakeHash(invoice.RHash) + require.NoError(ht, err) + + req := &routerrpc.SendPaymentRequest{ + PaymentRequest: invoice.PaymentRequest, + FeeLimitMsat: noFeeLimitMsat, + } + ht.SendPaymentAssertInflight(alice, req) + + intercepted := ht.ReceiveHtlcInterceptor(interceptor) + ht.AssertIncomingHTLCActive(bob, cpAB, invoice.RHash) + ht.AssertPaymentStatus(alice, payHash, lnrpc.Payment_IN_FLIGHT) + + closeStream, _ := ht.CloseChannelAssertPending( + alice, cpAB, true, + ) + ht.AssertStreamChannelForceClosed( + alice, cpAB, false, closeStream, + ) + ht.AssertChannelPendingForceClose(bob, cpAB) + + ht.AssertNumTxsInMempool(0) + err = interceptor.Send(&routerrpc.ForwardHtlcInterceptResponse{ + IncomingCircuitKey: intercepted.IncomingCircuitKey, + Action: routerrpc.ResolveHoldForwardAction_SETTLE, + Preimage: invoice.RPreimage, + }) + require.NoError(ht, err, "failed to settle intercepted HTLC") + + // The preimage should reach the contest resolver and register Bob's + // HTLC success input with the sweeper. + ht.AssertAtLeastNumPendingSweeps(bob, 1) + + // Give the sweeper another blockbeat to publish the sweep transaction. + ht.MineEmptyBlocks(1) + + ht.MineBlocksAndAssertNumTxes(1, 1) + ht.AssertPaymentStatus(alice, payHash, lnrpc.Payment_SUCCEEDED) + + // Bob's sweep is mined above. Clean up Alice's force close so the next + // test starts with an empty mempool. + ht.CleanupForceClose(alice) +} + // interceptorTestScenario is a helper struct to hold the test context and // provide the needed functionality. type interceptorTestScenario struct { From b32e432e0b577936d2738edfb57099398019ecf9 Mon Sep 17 00:00:00 2001 From: ziggie Date: Mon, 22 Jun 2026 08:35:04 -0300 Subject: [PATCH 097/134] htlcswitch: track held HTLC source Store held forwards as off-chain or on-chain entries instead of a raw InterceptedForward map. Off-chain entries keep the existing resume, fail, settle and auto-fail behavior. On-chain entries are settle-only and expire by pruning local interceptor state. When contractcourt re-offers a circuit that is already held off-chain, replace the stored entry with the on-chain forward so a later SETTLE reaches the witness beacon instead of the old link mailbox path. Also set the on-chain interceptor deadline to the HTLC refund timeout. This keeps the public interceptor deadline populated while ensuring only off-chain held entries use that value to fail back. Only off-chain held HTLCs can be released when an optional interceptor disconnects, because they can resume into the link forwarding flow. On-chain held HTLCs have no link flow to resume. Keep them in the held set so a reconnecting interceptor can replay and settle them while contractcourt waits for the preimage or on-chain expiry. Use distinct internal deadline types for off-chain auto-fail heights and on-chain settlement deadlines instead of overloading the intercepted packet field. Project both variants back into the existing router RPC auto_fail_height field to preserve wire compatibility. Reject mismatched held HTLC deadline types in tests. On-chain intercepted HTLCs can only be settled. Resume and fail actions already return concrete errors through the on-chain intercepted forward, so let those errors propagate to the interceptor client instead of converting them to success. Keep the held entry tracked on these errors so the client can reconnect and settle the HTLC later. (cherry picked from commit eb1193f80bbe5f0ec2f6618657f1deada5f6561c) --- htlcswitch/held_htlc_set.go | 377 ++++++++++++-- htlcswitch/held_htlc_set_test.go | 672 +++++++++++++++++++++---- htlcswitch/interceptable_switch.go | 202 +++++--- htlcswitch/interfaces.go | 31 +- htlcswitch/switch_test.go | 2 +- lnrpc/routerrpc/forward_interceptor.go | 2 +- witness_beacon.go | 7 + 7 files changed, 1084 insertions(+), 209 deletions(-) diff --git a/htlcswitch/held_htlc_set.go b/htlcswitch/held_htlc_set.go index c04880dc3..7c2ac1411 100644 --- a/htlcswitch/held_htlc_set.go +++ b/htlcswitch/held_htlc_set.go @@ -5,62 +5,333 @@ import ( "fmt" "github.com/lightningnetwork/lnd/graph/db/models" + "github.com/lightningnetwork/lnd/lnwire" ) -// heldHtlcSet keeps track of outstanding intercepted forwards. It exposes -// several methods to manipulate the underlying map structure in a consistent -// way. +var ( + // ErrCannotResumeOnChain is returned when an on-chain held HTLC is + // resolved with a resume action. + ErrCannotResumeOnChain = errors.New( + "cannot resume held htlc in the on-chain flow", + ) + + // ErrCannotFailOnChain is returned when an on-chain held HTLC is + // resolved with a fail action. + ErrCannotFailOnChain = errors.New( + "cannot fail held htlc in the on-chain flow", + ) + + // errNilHeldForward is returned when the held HTLC constructors or + // add helpers are given a nil InterceptedForward. + errNilHeldForward = errors.New("nil held htlc forward") + + // errInvalidHeldDeadline is returned when a held HTLC has an + // interceptor deadline that is not a positive block height. + errInvalidHeldDeadline = errors.New( + "invalid held htlc interceptor deadline", + ) + + // errInvalidHeldDeadlineType is returned when a held HTLC entry is + // created with the wrong deadline type for its source. + errInvalidHeldDeadlineType = errors.New( + "invalid held htlc interceptor deadline type", + ) +) + +// heldEntry models the behavior of a held HTLC based on whether it is still +// controlled by the off-chain link flow or the on-chain contractcourt flow. +type heldEntry interface { + // interceptedForward returns the forward that should be replayed to the + // external interceptor. + interceptedForward() InterceptedForward + + // resolve applies an interceptor resolution to the held entry. + resolve(*FwdResolution) error + + // expire expires the held entry at the given block height. The boolean + // return value indicates whether the entry should be removed. + expire(height uint32) (bool, error) +} + +// offChainHeld is a held HTLC that is still controlled by the off-chain link +// flow. +type offChainHeld struct { + fwd InterceptedForward + + // autoFailHeight is the block height at which the held off-chain HTLC + // must be failed back to avoid forcing the incoming channel closed. + autoFailHeight uint32 +} + +// Assert that offChainHeld implements heldEntry. +var _ heldEntry = (*offChainHeld)(nil) + +// newOffChainHeld creates a held off-chain HTLC entry and validates that it has +// a positive auto-fail height. +func newOffChainHeld(fwd InterceptedForward) (*offChainHeld, error) { + if fwd == nil { + return nil, errNilHeldForward + } + + autoFailHeight, err := fwd.Packet().Deadline.LeftToSome().UnwrapOrErr( + errInvalidHeldDeadlineType, + ) + if err != nil { + return nil, err + } + + if autoFailHeight <= 0 { + return nil, fmt.Errorf("%w: %v", errInvalidHeldDeadline, + autoFailHeight) + } + + return &offChainHeld{ + fwd: fwd, + autoFailHeight: uint32(autoFailHeight), + }, nil +} + +// interceptedForward returns the intercepted forward backing the off-chain +// entry. +func (h *offChainHeld) interceptedForward() InterceptedForward { + return h.fwd +} + +// release resumes the held off-chain HTLC into the normal link forwarding +// flow. +func (h *offChainHeld) release() error { + return h.fwd.Resume() +} + +// resolve applies an interceptor resolution to the held off-chain HTLC. +func (h *offChainHeld) resolve(res *FwdResolution) error { + switch res.Action { + case FwdActionResume: + return h.fwd.Resume() + + case FwdActionResumeModified: + return h.fwd.ResumeModified( + res.InAmountMsat, res.OutAmountMsat, + res.OutWireCustomRecords, + ) + + case FwdActionSettle: + return h.fwd.Settle(res.Preimage) + + case FwdActionFail: + if len(res.FailureMessage) > 0 { + return h.fwd.Fail(res.FailureMessage) + } + + return h.fwd.FailWithCode(res.FailureCode) + + default: + return fmt.Errorf("unrecognized action %v", res.Action) + } +} + +// expire fails back the held off-chain HTLC once its auto-fail height has been +// reached. +func (h *offChainHeld) expire(height uint32) (bool, error) { + if h.autoFailHeight > height { + return false, nil + } + + err := h.fwd.FailWithCode(lnwire.CodeTemporaryChannelFailure) + if err != nil { + return false, err + } + + return true, nil +} + +// onChainHeld is a held HTLC that is controlled by the on-chain contractcourt +// flow. +type onChainHeld struct { + fwd InterceptedForward + + // settleDeadline is the on-chain HTLC expiry. Once this height is + // reached, the remote party can also sweep the HTLC using the timeout + // path, so any late preimage would race that spend. At that point the + // interceptor entry is pruned locally instead of failed back through + // the link. + settleDeadline uint32 +} + +// Assert that onChainHeld implements heldEntry. +var _ heldEntry = (*onChainHeld)(nil) + +// newOnChainHeld creates a held on-chain HTLC entry and validates that it has a +// positive settlement deadline. +func newOnChainHeld(fwd InterceptedForward) (*onChainHeld, error) { + if fwd == nil { + return nil, errNilHeldForward + } + + settleDeadline, err := fwd.Packet().Deadline.RightToSome().UnwrapOrErr( + errInvalidHeldDeadlineType, + ) + if err != nil { + return nil, err + } + + if settleDeadline <= 0 { + return nil, fmt.Errorf("%w: %v", errInvalidHeldDeadline, + settleDeadline) + } + + return &onChainHeld{ + fwd: fwd, + settleDeadline: uint32(settleDeadline), + }, nil +} + +// interceptedForward returns the intercepted forward backing the on-chain +// entry. +func (h *onChainHeld) interceptedForward() InterceptedForward { + return h.fwd +} + +// resolve applies an interceptor resolution to the held on-chain HTLC. +func (h *onChainHeld) resolve(res *FwdResolution) error { + switch res.Action { + case FwdActionSettle: + return h.fwd.Settle(res.Preimage) + + case FwdActionFail: + return ErrCannotFailOnChain + + case FwdActionResume: + return ErrCannotResumeOnChain + + case FwdActionResumeModified: + return ErrCannotResumeOnChain + + default: + return fmt.Errorf("unrecognized action %v", res.Action) + } +} + +// expire reports whether the held on-chain HTLC should be pruned locally +// because its settlement deadline has been reached. +func (h *onChainHeld) expire(height uint32) (bool, error) { + return h.settleDeadline <= height, nil +} + +// heldHtlcExpireError records an error returned while expiring a held HTLC. +type heldHtlcExpireError struct { + key models.CircuitKey + err error +} + +// heldHtlcReleaseError records an error returned while releasing a held HTLC. +type heldHtlcReleaseError struct { + key models.CircuitKey + err error +} + +// heldHtlcSet keeps track of outstanding intercepted forwards. It models +// whether each forward is still controlled by the off-chain link flow or has +// moved to the on-chain contractcourt flow. type heldHtlcSet struct { - set map[models.CircuitKey]InterceptedForward + set map[models.CircuitKey]heldEntry } func newHeldHtlcSet() *heldHtlcSet { return &heldHtlcSet{ - set: make(map[models.CircuitKey]InterceptedForward), + set: make(map[models.CircuitKey]heldEntry), } } // forEach iterates over all held forwards and calls the given callback for each // of them. func (h *heldHtlcSet) forEach(cb func(InterceptedForward)) { - for _, fwd := range h.set { - cb(fwd) + for _, entry := range h.set { + cb(entry.interceptedForward()) } } -// popAll calls the callback for each forward and removes them from the set. -func (h *heldHtlcSet) popAll(cb func(InterceptedForward)) { - for _, fwd := range h.set { - cb(fwd) - } +// releaseAllOffChainHeld releases off-chain entries when the optional +// interceptor disconnects. On-chain entries are kept because there is no link +// flow to resume, preserving the replay/settle handle while contractcourt waits +// for the preimage or on-chain expiry. +func (h *heldHtlcSet) releaseAllOffChainHeld() []heldHtlcReleaseError { + var errs []heldHtlcReleaseError - h.set = make(map[models.CircuitKey]InterceptedForward) -} - -// popAutoFails calls the callback for each forward that has an auto-fail height -// equal or less then the specified pop height and removes them from the set. -func (h *heldHtlcSet) popAutoFails(height uint32, cb func(InterceptedForward)) { - for key, fwd := range h.set { - if uint32(fwd.Packet().AutoFailHeight) > height { + for key, entry := range h.set { + offChain, ok := entry.(*offChainHeld) + if !ok { continue } - cb(fwd) + if err := offChain.release(); err != nil { + errs = append(errs, heldHtlcReleaseError{ + key: key, + err: err, + }) + + // Keep the entry tracked so it can still be resolved or + // failed back by the normal expiry path. + continue + } delete(h.set, key) } + + return errs } -// pop returns the specified forward and removes it from the set. -func (h *heldHtlcSet) pop(key models.CircuitKey) (InterceptedForward, error) { - intercepted, ok := h.set[key] - if !ok { - return nil, fmt.Errorf("fwd %v not found", key) +// removeOnChainHeld removes an on-chain held entry by circuit key. Off-chain +// entries are left untouched because their lifecycle is owned by the link flow, +// not contractcourt. +func (h *heldHtlcSet) removeOnChainHeld(key models.CircuitKey) bool { + if _, ok := h.set[key].(*onChainHeld); !ok { + return false } delete(h.set, key) - return intercepted, nil + return true +} + +// expire expires held forwards whose deadline has passed. +func (h *heldHtlcSet) expire(height uint32) []heldHtlcExpireError { + var errs []heldHtlcExpireError + + for key, entry := range h.set { + remove, err := entry.expire(height) + if err != nil { + errs = append(errs, heldHtlcExpireError{ + key: key, + err: err, + }) + + continue + } + + if remove { + delete(h.set, key) + } + } + + return errs +} + +// resolve applies the given resolution and removes the forward from the set if +// the resolution succeeds. +func (h *heldHtlcSet) resolve(res *FwdResolution) error { + entry, ok := h.set[res.Key] + if !ok { + return fmt.Errorf("%w: %v", ErrFwdNotExists, res.Key) + } + + if err := entry.resolve(res); err != nil { + return err + } + + delete(h.set, res.Key) + + return nil } // exists tests whether the specified forward is part of the set. @@ -70,20 +341,56 @@ func (h *heldHtlcSet) exists(key models.CircuitKey) bool { return ok } -// push adds the specified forward to the set. An error is returned if the -// forward exists already. -func (h *heldHtlcSet) push(key models.CircuitKey, - fwd InterceptedForward) error { - +// addOffChain adds an off-chain forward to the set. If the forward already +// exists, the duplicate is ignored because callers should have handled it +// before insertion. +func (h *heldHtlcSet) addOffChain(fwd InterceptedForward) error { if fwd == nil { - return errors.New("nil fwd pushed") + return errNilHeldForward } + key := fwd.Packet().IncomingCircuit if h.exists(key) { - return errors.New("htlc already exists in set") + log.Warnf("Ignoring duplicate off-chain held htlc %v", key) + + return nil } - h.set[key] = fwd + entry, err := newOffChainHeld(fwd) + if err != nil { + return err + } + + h.set[key] = entry + + return nil +} + +// addOnChain adds an on-chain forward to the set. If the same HTLC is currently +// held off-chain, it is replaced so future resolutions go to the witness beacon +// instead of the old link mailbox path. +func (h *heldHtlcSet) addOnChain(fwd InterceptedForward) error { + if fwd == nil { + return errNilHeldForward + } + + key := fwd.Packet().IncomingCircuit + + if _, ok := h.set[key].(*onChainHeld); ok { + return nil + } + + if _, ok := h.set[key].(*offChainHeld); ok { + log.Infof("Promoting held htlc %v from off-chain to "+ + "on-chain resolution", key) + } + + entry, err := newOnChainHeld(fwd) + if err != nil { + return err + } + + h.set[key] = entry return nil } diff --git a/htlcswitch/held_htlc_set_test.go b/htlcswitch/held_htlc_set_test.go index ca1a1750b..bf38bd5df 100644 --- a/htlcswitch/held_htlc_set_test.go +++ b/htlcswitch/held_htlc_set_test.go @@ -1,126 +1,602 @@ package htlcswitch import ( + "errors" "testing" + "time" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/graph/db/models" + lntestmock "github.com/lightningnetwork/lnd/lntest/mock" + "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/lnwire" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) +var errTestForward = errors.New("test forward error") + +// mockInterceptedForward is an InterceptedForward test double that records +// resolution calls and returns configured errors. +type mockInterceptedForward struct { + mock.Mock + + packet InterceptedPacket +} + +// newMockInterceptedForward creates a mock intercepted forward with the given +// circuit key and auto-fail deadline. +func newMockInterceptedForward(key models.CircuitKey, + deadline int32) *mockInterceptedForward { + + return &mockInterceptedForward{ + packet: InterceptedPacket{ + IncomingCircuit: key, + Deadline: fn.NewLeft[ + OffChainAutoFailHeight, OnChainSettleDeadline, + ](OffChainAutoFailHeight(deadline)), + }, + } +} + +// newMockOnChainInterceptedForward creates a mock on-chain intercepted forward +// with the given circuit key and settlement deadline. +func newMockOnChainInterceptedForward(key models.CircuitKey, + deadline int32) *mockInterceptedForward { + + return &mockInterceptedForward{ + packet: InterceptedPacket{ + IncomingCircuit: key, + Deadline: fn.NewRight[ + OffChainAutoFailHeight, OnChainSettleDeadline, + ](OnChainSettleDeadline(deadline)), + }, + } +} + +// Packet returns the intercepted packet represented by the mock. +func (m *mockInterceptedForward) Packet() InterceptedPacket { + return m.packet +} + +// Resume records a resume call and returns the configured return error. +func (m *mockInterceptedForward) Resume() error { + args := m.Called() + + return args.Error(0) +} + +// ResumeModified records a modified resume call and returns the configured +// return error. +func (m *mockInterceptedForward) ResumeModified( + _ fn.Option[lnwire.MilliSatoshi], + _ fn.Option[lnwire.MilliSatoshi], + _ fn.Option[lnwire.CustomRecords]) error { + + args := m.Called() + + return args.Error(0) +} + +// Settle records a settle call and returns the configured return error. +func (m *mockInterceptedForward) Settle(preimage lntypes.Preimage) error { + args := m.Called(preimage) + + return args.Error(0) +} + +// Fail records an encrypted failure call and returns the configured return +// error. +func (m *mockInterceptedForward) Fail(reason []byte) error { + args := m.Called(reason) + + return args.Error(0) +} + +// FailWithCode records a failure-code call and returns the configured +// return error. +func (m *mockInterceptedForward) FailWithCode(code lnwire.FailCode) error { + args := m.Called(code) + + return args.Error(0) +} + +// testCircuitKey returns a stable circuit key for held HTLC set tests. +func testCircuitKey() models.CircuitKey { + return models.CircuitKey{ + ChanID: lnwire.NewShortChanIDFromInt(1), + HtlcID: 2, + } +} + +// TestHeldHtlcSetEmpty verifies empty held HTLC set behavior. func TestHeldHtlcSetEmpty(t *testing.T) { set := newHeldHtlcSet() - // Test operations on an empty set. require.False(t, set.exists(models.CircuitKey{})) - - _, err := set.pop(models.CircuitKey{}) - require.Error(t, err) - - set.popAll( - func(_ InterceptedForward) { - require.Fail(t, "unexpected fwd") - }, - ) + require.ErrorIs(t, set.resolve(&FwdResolution{}), ErrFwdNotExists) + require.Empty(t, set.releaseAllOffChainHeld()) } -func TestHeldHtlcSet(t *testing.T) { +// TestHeldHtlcSetRejectsInvalidDeadline verifies invalid deadlines are +// rejected for both off-chain and on-chain held entries. +func TestHeldHtlcSetRejectsInvalidDeadline(t *testing.T) { set := newHeldHtlcSet() + key := testCircuitKey() - key := models.CircuitKey{ - ChanID: lnwire.NewShortChanIDFromInt(1), - HtlcID: 2, + require.Error(t, set.addOffChain(newMockInterceptedForward(key, 0))) + require.Error(t, set.addOffChain(newMockInterceptedForward(key, -1))) + require.Error(t, set.addOffChain( + newMockOnChainInterceptedForward(key, 100), + )) + require.Error(t, set.addOnChain( + newMockOnChainInterceptedForward(key, 0), + )) + require.Error(t, set.addOnChain( + newMockOnChainInterceptedForward(key, -1), + )) + require.Error(t, set.addOnChain(newMockInterceptedForward(key, 100))) +} + +// TestHeldHtlcSetOffChainResolve verifies off-chain resolutions call through +// to the backing intercepted forward and remove the held entry. +func TestHeldHtlcSetOffChainResolve(t *testing.T) { + set := newHeldHtlcSet() + key := testCircuitKey() + fwd := newMockInterceptedForward(key, 100) + fwd.On("Resume").Return(nil).Once() + + require.NoError(t, set.addOffChain(fwd)) + require.NoError(t, set.resolve(&FwdResolution{ + Key: key, + Action: FwdActionResume, + })) + fwd.AssertExpectations(t) + require.False(t, set.exists(key)) +} + +// TestHeldHtlcSetAddOffChainKeepsExisting verifies that duplicate off-chain +// forwards keep the existing held entry. +func TestHeldHtlcSetAddOffChainKeepsExisting(t *testing.T) { + set := newHeldHtlcSet() + key := testCircuitKey() + first := newMockInterceptedForward(key, 100) + second := newMockInterceptedForward(key, 100) + first.On("Resume").Return(nil).Once() + + require.NoError(t, set.addOffChain(first)) + require.NoError(t, set.addOffChain(second)) + require.NoError(t, set.resolve(&FwdResolution{ + Key: key, + Action: FwdActionResume, + })) + + first.AssertExpectations(t) + second.AssertNotCalled(t, "Resume") +} + +// TestInterceptableSwitchForwardOffChainAlreadyHeld verifies that normal +// off-chain forwarding handles duplicates before adding them to the held set. +func TestInterceptableSwitchForwardOffChainAlreadyHeld(t *testing.T) { + key := testCircuitKey() + fwd := newMockInterceptedForward(key, 100) + s := &InterceptableSwitch{ + heldHtlcSet: newHeldHtlcSet(), } - // Test pushing a nil forward. - require.Error(t, set.push(key, nil)) + require.NoError(t, s.heldHtlcSet.addOffChain(fwd)) - // Test pushing a forward. - fwd := &interceptedForward{ - htlc: &lnwire.UpdateAddHTLC{}, - } - require.NoError(t, set.push(key, fwd)) - - // Re-pushing should fail. - require.Error(t, set.push(key, fwd)) - - // Test popping the fwd. - poppedFwd, err := set.pop(key) + handled, err := s.forwardOffChain( + newMockInterceptedForward(key, 100), true, + ) require.NoError(t, err) - require.Equal(t, fwd, poppedFwd) - - _, err = set.pop(key) - require.Error(t, err) - - // Pushing the forward again. - require.NoError(t, set.push(key, fwd)) - - // Test for each. - var cbCalled bool - set.forEach(func(_ InterceptedForward) { - cbCalled = true - - require.Equal(t, fwd, poppedFwd) - }) - require.True(t, cbCalled) - - // Test popping all forwards. - cbCalled = false - set.popAll( - func(_ InterceptedForward) { - cbCalled = true - - require.Equal(t, fwd, poppedFwd) - }, - ) - require.True(t, cbCalled) - - _, err = set.pop(key) - require.Error(t, err) + require.True(t, handled) } -func TestHeldHtlcSetAutoFails(t *testing.T) { +// TestHeldHtlcSetResolveKeepsEntryOnError verifies failed resolutions keep the +// held entry available for retry. +func TestHeldHtlcSetResolveKeepsEntryOnError(t *testing.T) { set := newHeldHtlcSet() + key := testCircuitKey() + fwd := newMockInterceptedForward(key, 100) + fwd.On("Settle", lntypes.Preimage{}).Return(errTestForward).Once() - key := models.CircuitKey{ - ChanID: lnwire.NewShortChanIDFromInt(1), - HtlcID: 2, - } + require.NoError(t, set.addOffChain(fwd)) + require.ErrorIs(t, set.resolve(&FwdResolution{ + Key: key, + Action: FwdActionSettle, + }), errTestForward) - const autoFailHeight = 100 - fwd := &interceptedForward{ - packet: &htlcPacket{}, - htlc: &lnwire.UpdateAddHTLC{}, - autoFailHeight: autoFailHeight, - } - require.NoError(t, set.push(key, fwd)) - - // Test popping auto fails up to one block before the auto-fail height - // of our forward. - set.popAutoFails( - autoFailHeight-1, - func(_ InterceptedForward) { - require.Fail(t, "unexpected fwd") - }, - ) - - // Popping succeeds at the auto-fail height. - cbCalled := false - set.popAutoFails( - autoFailHeight, - func(poppedFwd InterceptedForward) { - cbCalled = true - - require.Equal(t, fwd, poppedFwd) - }, - ) - require.True(t, cbCalled) - - // After this, there should be nothing more to pop. - set.popAutoFails( - autoFailHeight, - func(_ InterceptedForward) { - require.Fail(t, "unexpected fwd") - }, - ) + require.True(t, set.exists(key)) + fwd.AssertExpectations(t) +} + +// TestHeldHtlcSetReleaseAllOffChainHeld verifies an optional interceptor +// disconnect resumes off-chain entries and clears them from the set. +func TestHeldHtlcSetReleaseAllOffChainHeld(t *testing.T) { + set := newHeldHtlcSet() + key := testCircuitKey() + fwd := newMockInterceptedForward(key, 100) + fwd.On("Resume").Return(nil).Once() + + require.NoError(t, set.addOffChain(fwd)) + require.Empty(t, set.releaseAllOffChainHeld()) + require.False(t, set.exists(key)) + fwd.AssertExpectations(t) +} + +// TestHeldHtlcSetReleaseAllOffChainHeldKeepsOnChain verifies an optional +// interceptor disconnect keeps on-chain entries available for replay if the +// interceptor reconnects before expiry. +func TestHeldHtlcSetReleaseAllOffChainHeldKeepsOnChain(t *testing.T) { + set := newHeldHtlcSet() + key := testCircuitKey() + fwd := newMockOnChainInterceptedForward(key, 100) + + require.NoError(t, set.addOnChain(fwd)) + require.Empty(t, set.releaseAllOffChainHeld()) + require.True(t, set.exists(key)) + fwd.AssertNotCalled(t, "Resume") +} + +// TestHeldHtlcSetReleaseAllOffChainHeldKeepsReleaseErrors verifies release +// errors leave off-chain entries available for later resolution or expiry. +func TestHeldHtlcSetReleaseAllOffChainHeldKeepsReleaseErrors(t *testing.T) { + set := newHeldHtlcSet() + key := testCircuitKey() + fwd := newMockInterceptedForward(key, 100) + fwd.On("Resume").Return(errTestForward).Once() + + require.NoError(t, set.addOffChain(fwd)) + + errs := set.releaseAllOffChainHeld() + require.Len(t, errs, 1) + require.Equal(t, key, errs[0].key) + require.ErrorIs(t, errs[0].err, errTestForward) + require.True(t, set.exists(key)) + fwd.AssertExpectations(t) +} + +// TestHeldHtlcSetRemoveOnChainHeld verifies contractcourt teardown only removes +// on-chain entries. +func TestHeldHtlcSetRemoveOnChainHeld(t *testing.T) { + set := newHeldHtlcSet() + key := testCircuitKey() + + offChain := newMockInterceptedForward(key, 100) + require.NoError(t, set.addOffChain(offChain)) + require.False(t, set.removeOnChainHeld(key)) + require.True(t, set.exists(key)) + + onChain := newMockOnChainInterceptedForward(key, 100) + require.NoError(t, set.addOnChain(onChain)) + require.True(t, set.removeOnChainHeld(key)) + require.False(t, set.exists(key)) + require.False(t, set.removeOnChainHeld(key)) +} + +// TestHeldHtlcSetOffChainExpire verifies off-chain expiry fails the HTLC back. +func TestHeldHtlcSetOffChainExpire(t *testing.T) { + set := newHeldHtlcSet() + key := testCircuitKey() + fwd := newMockInterceptedForward(key, 100) + + require.NoError(t, set.addOffChain(fwd)) + + require.Empty(t, set.expire(99)) + require.True(t, set.exists(key)) + fwd.AssertNotCalled(t, "FailWithCode", mock.Anything) + + fwd.On( + "FailWithCode", + lnwire.CodeTemporaryChannelFailure, + ).Return(nil).Once() + require.Empty(t, set.expire(100)) + require.False(t, set.exists(key)) + fwd.AssertExpectations(t) +} + +// TestHeldHtlcSetOffChainExpireKeepsEntryOnError verifies expiry errors keep +// the off-chain entry available for retry. +func TestHeldHtlcSetOffChainExpireKeepsEntryOnError(t *testing.T) { + set := newHeldHtlcSet() + key := testCircuitKey() + fwd := newMockInterceptedForward(key, 100) + fwd.On( + "FailWithCode", lnwire.CodeTemporaryChannelFailure, + ).Return(errTestForward).Once() + + require.NoError(t, set.addOffChain(fwd)) + + errs := set.expire(100) + require.Len(t, errs, 1) + require.ErrorIs(t, errs[0].err, errTestForward) + require.Equal(t, key, errs[0].key) + require.True(t, set.exists(key)) + fwd.AssertExpectations(t) +} + +// TestHeldHtlcSetOnChainResolve verifies on-chain entries reject non-settle +// resolutions directly and remain held until settlement. +func TestHeldHtlcSetOnChainResolve(t *testing.T) { + set := newHeldHtlcSet() + key := testCircuitKey() + fwd := newMockOnChainInterceptedForward(key, 100) + fwd.On("Settle", lntypes.Preimage{}).Return(nil).Once() + + require.NoError(t, set.addOnChain(fwd)) + require.ErrorIs(t, set.resolve(&FwdResolution{ + Key: key, + Action: FwdActionFail, + FailureCode: lnwire.CodeTemporaryChannelFailure, + }), ErrCannotFailOnChain) + require.True(t, set.exists(key)) + + require.ErrorIs(t, set.resolve(&FwdResolution{ + Key: key, + Action: FwdActionResume, + }), ErrCannotResumeOnChain) + require.True(t, set.exists(key)) + + require.ErrorIs(t, set.resolve(&FwdResolution{ + Key: key, + Action: FwdActionResumeModified, + }), ErrCannotResumeOnChain) + require.True(t, set.exists(key)) + + require.NoError(t, set.resolve(&FwdResolution{ + Key: key, + Action: FwdActionSettle, + })) + fwd.AssertExpectations(t) + fwd.AssertNotCalled(t, "Fail", mock.Anything) + fwd.AssertNotCalled(t, "FailWithCode", mock.Anything) + fwd.AssertNotCalled(t, "Resume") + fwd.AssertNotCalled(t, "ResumeModified", mock.Anything, mock.Anything, + mock.Anything) + require.False(t, set.exists(key)) +} + +// TestHeldHtlcSetOnChainExpirePrunes verifies on-chain expiry only prunes the +// local held entry. +func TestHeldHtlcSetOnChainExpirePrunes(t *testing.T) { + set := newHeldHtlcSet() + key := testCircuitKey() + fwd := newMockOnChainInterceptedForward(key, 100) + + require.NoError(t, set.addOnChain(fwd)) + + require.Empty(t, set.expire(99)) + require.True(t, set.exists(key)) + + require.Empty(t, set.expire(100)) + require.False(t, set.exists(key)) + fwd.AssertNotCalled(t, "FailWithCode", mock.Anything) +} + +// TestHeldHtlcSetOnChainReplacesOffChain verifies on-chain entries replace +// earlier off-chain entries with the same circuit key. +func TestHeldHtlcSetOnChainReplacesOffChain(t *testing.T) { + set := newHeldHtlcSet() + key := testCircuitKey() + offChain := newMockInterceptedForward(key, 100) + onChain := newMockOnChainInterceptedForward(key, 100) + onChain.On("Settle", lntypes.Preimage{}).Return(nil).Once() + + require.NoError(t, set.addOffChain(offChain)) + require.NoError(t, set.addOnChain(onChain)) + + require.NoError(t, set.resolve(&FwdResolution{ + Key: key, + Action: FwdActionSettle, + })) + + offChain.AssertNotCalled(t, "Settle", mock.Anything) + onChain.AssertExpectations(t) +} + +// TestInterceptableSwitchForwardOnChain verifies on-chain intercept handling +// for fresh and already-held HTLCs. +func TestInterceptableSwitchForwardOnChain(t *testing.T) { + key := testCircuitKey() + + var intercepted []InterceptedPacket + interceptor := func(packet InterceptedPacket) error { + intercepted = append(intercepted, packet) + + return nil + } + + t.Run("fresh on-chain htlc is sent", func(t *testing.T) { + s := &InterceptableSwitch{ + heldHtlcSet: newHeldHtlcSet(), + interceptor: interceptor, + } + fwd := newMockOnChainInterceptedForward(key, 100) + + require.NoError(t, s.interceptOnChain(fwd)) + require.Len(t, intercepted, 1) + require.Equal(t, key, intercepted[0].IncomingCircuit) + }) + + t.Run("on-chain htlc replaces off-chain htlc and notifies", + func(t *testing.T) { + intercepted = nil + s := &InterceptableSwitch{ + heldHtlcSet: newHeldHtlcSet(), + interceptor: interceptor, + } + offChain := newMockInterceptedForward(key, 80) + onChain := newMockOnChainInterceptedForward(key, 100) + onChain.On("Settle", lntypes.Preimage{}).Return( + nil, + ).Once() + + require.NoError(t, s.heldHtlcSet.addOffChain(offChain)) + require.NoError(t, s.interceptOnChain(onChain)) + require.Len(t, intercepted, 1) + require.Equal(t, key, intercepted[0].IncomingCircuit) + require.Equal( + t, int32(100), intercepted[0].AutoFailHeight(), + ) + + require.NoError(t, s.resolve(&FwdResolution{ + Key: key, + Action: FwdActionSettle, + })) + offChain.AssertNotCalled(t, "Settle", mock.Anything) + onChain.AssertExpectations(t) + }) + + t.Run("on-chain htlc replays after disconnect", func(t *testing.T) { + intercepted = nil + s := &InterceptableSwitch{ + heldHtlcSet: newHeldHtlcSet(), + interceptor: interceptor, + } + fwd := newMockOnChainInterceptedForward(key, 100) + fwd.On("Settle", lntypes.Preimage{}).Return(nil).Once() + + require.NoError(t, s.interceptOnChain(fwd)) + require.Len(t, intercepted, 1) + + s.setInterceptor(nil) + require.True(t, s.heldHtlcSet.exists(key)) + + intercepted = nil + s.setInterceptor(interceptor) + require.Len(t, intercepted, 1) + require.Equal(t, key, intercepted[0].IncomingCircuit) + + require.NoError(t, s.resolve(&FwdResolution{ + Key: key, + Action: FwdActionSettle, + })) + fwd.AssertExpectations(t) + require.False(t, s.heldHtlcSet.exists(key)) + }) + + t.Run("duplicate on-chain htlc is not sent", func(t *testing.T) { + intercepted = nil + s := &InterceptableSwitch{ + heldHtlcSet: newHeldHtlcSet(), + interceptor: interceptor, + } + fwd := newMockOnChainInterceptedForward(key, 100) + + require.NoError(t, s.interceptOnChain(fwd)) + require.Len(t, intercepted, 1) + + intercepted = nil + require.NoError(t, s.interceptOnChain(fwd)) + require.Empty(t, intercepted) + }) + + t.Run("on-chain htlc removed after teardown", func(t *testing.T) { + intercepted = nil + s := &InterceptableSwitch{ + heldHtlcSet: newHeldHtlcSet(), + interceptor: interceptor, + } + fwd := newMockOnChainInterceptedForward(key, 100) + + require.NoError(t, s.interceptOnChain(fwd)) + require.Len(t, intercepted, 1) + + s.removeOnChainIntercept(key) + require.False(t, s.heldHtlcSet.exists(key)) + + intercepted = nil + s.setInterceptor(interceptor) + require.Empty(t, intercepted) + }) +} + +// TestInterceptableSwitchRemoveOnChainIntercept verifies that the public +// teardown path removes an on-chain hold through the switch run loop. +func TestInterceptableSwitchRemoveOnChainIntercept(t *testing.T) { + notifier := &lntestmock.ChainNotifier{ + EpochChan: make(chan *chainntnfs.BlockEpoch, 1), + } + notifier.EpochChan <- &chainntnfs.BlockEpoch{Height: 1} + + s, err := NewInterceptableSwitch(&InterceptableSwitchConfig{ + Notifier: notifier, + CltvRejectDelta: 10, + CltvInterceptDelta: 13, + }) + require.NoError(t, err) + require.NoError(t, s.Start()) + defer func() { + require.NoError(t, s.Stop()) + }() + + intercepted := make(chan InterceptedPacket, 2) + s.SetInterceptor(func(packet InterceptedPacket) error { + intercepted <- packet + + return nil + }) + + key := testCircuitKey() + require.NoError(t, s.ForwardPacket( + newMockOnChainInterceptedForward(key, 100), + )) + select { + case packet := <-intercepted: + require.Equal(t, key, packet.IncomingCircuit) + + case <-time.After(time.Second): + require.Fail(t, "on-chain hold not intercepted") + } + + require.NoError(t, s.RemoveOnChainIntercept(key)) + + // Re-registering the interceptor replays all currently held HTLCs. + // The removed on-chain hold should not be replayed. + s.SetInterceptor(func(packet InterceptedPacket) error { + intercepted <- packet + + return nil + }) + + // Synchronize with the switch event loop so any replay triggered by the + // interceptor registration above has already run. + require.NoError(t, s.RemoveOnChainIntercept(models.CircuitKey{})) + + select { + case packet := <-intercepted: + require.Failf(t, "unexpected replay", "packet=%v", packet) + + default: + } +} + +// TestInterceptableSwitchForwardPacketReturnsHoldError verifies that +// ForwardPacket returns the error produced while adding the on-chain hold. +func TestInterceptableSwitchForwardPacketReturnsHoldError(t *testing.T) { + notifier := &lntestmock.ChainNotifier{ + EpochChan: make(chan *chainntnfs.BlockEpoch, 1), + } + notifier.EpochChan <- &chainntnfs.BlockEpoch{Height: 1} + + s, err := NewInterceptableSwitch(&InterceptableSwitchConfig{ + Notifier: notifier, + CltvRejectDelta: 10, + CltvInterceptDelta: 13, + }) + require.NoError(t, err) + require.NoError(t, s.Start()) + defer func() { + require.NoError(t, s.Stop()) + }() + + key := testCircuitKey() + err = s.ForwardPacket(newMockInterceptedForward(key, 100)) + require.ErrorIs(t, err, errInvalidHeldDeadlineType) + require.False(t, s.heldHtlcSet.exists(key)) + + err = s.ForwardPacket(nil) + require.ErrorIs(t, err, errNilHeldForward) } diff --git a/htlcswitch/interceptable_switch.go b/htlcswitch/interceptable_switch.go index 3d0bd90ed..ac2d24ccc 100644 --- a/htlcswitch/interceptable_switch.go +++ b/htlcswitch/interceptable_switch.go @@ -50,7 +50,11 @@ type InterceptableSwitch struct { // interceptor client. resolutionChan chan *fwdResolution - onchainIntercepted chan InterceptedForward + onchainIntercepted chan *onchainInterceptRequest + + // onchainInterceptDone receives circuit keys for on-chain intercepted + // forwards whose contractcourt resolver has finished. + onchainInterceptDone chan models.CircuitKey // interceptorRegistration is a channel that we use to synchronize // client connect and disconnect. @@ -99,6 +103,11 @@ type interceptedPackets struct { isReplay bool } +type onchainInterceptRequest struct { + fwd InterceptedForward + errChan chan error +} + // FwdAction defines the various resolution types. type FwdAction int @@ -195,7 +204,8 @@ func NewInterceptableSwitch(cfg *InterceptableSwitchConfig) ( return &InterceptableSwitch{ htlcSwitch: cfg.Switch, intercepted: make(chan *interceptedPackets), - onchainIntercepted: make(chan InterceptedForward), + onchainIntercepted: make(chan *onchainInterceptRequest), + onchainInterceptDone: make(chan models.CircuitKey), interceptorRegistration: make(chan ForwardInterceptor), heldHtlcSet: newHeldHtlcSet(), resolutionChan: make(chan *fwdResolution), @@ -317,18 +327,20 @@ func (s *InterceptableSwitch) run() error { log.Errorf("Cannot forward packets: %v", err) } - case fwd := <-s.onchainIntercepted: - // For on-chain interceptions, we don't know if it has - // already been offered before. This information is in - // the forwarding package which isn't easily accessible - // from contractcourt. It is likely though that it was - // already intercepted in the off-chain flow. And even - // if not, it is safe to signal replay so that we won't - // unexpectedly skip over this htlc. - if _, err := s.forward(fwd, true); err != nil { - return err + case req := <-s.onchainIntercepted: + notify, err := s.holdOnChain(req.fwd) + req.errChan <- err + if err != nil { + continue } + if s.interceptor != nil && notify { + s.sendForward(req.fwd) + } + + case key := <-s.onchainInterceptDone: + s.removeOnChainIntercept(key) + case res := <-s.resolutionChan: res.errChan <- s.resolve(res.resolution) @@ -339,8 +351,10 @@ func (s *InterceptableSwitch) run() error { s.currentHeight = currentBlock.Height - // A new block is appended. Fail any held htlcs that - // expire at this height to prevent channel force-close. + // A new block is appended. Expire any held HTLCs whose + // deadline has passed. Off-chain HTLCs fail back, while + // on-chain HTLCs are only pruned from the local hold + // set. s.failExpiredHtlcs() case <-s.quit: @@ -350,17 +364,11 @@ func (s *InterceptableSwitch) run() error { } func (s *InterceptableSwitch) failExpiredHtlcs() { - s.heldHtlcSet.popAutoFails( - uint32(s.currentHeight), - func(fwd InterceptedForward) { - err := fwd.FailWithCode( - lnwire.CodeTemporaryChannelFailure, - ) - if err != nil { - log.Errorf("Cannot fail packet: %v", err) - } - }, - ) + errs := s.heldHtlcSet.expire(uint32(s.currentHeight)) + for _, expireErr := range errs { + log.Errorf("Cannot expire held htlc %v: %v", expireErr.key, + expireErr.err) + } } func (s *InterceptableSwitch) sendForward(fwd InterceptedForward) { @@ -394,48 +402,20 @@ func (s *InterceptableSwitch) setInterceptor(interceptor ForwardInterceptor) { return } - // Interceptor is not required. Release held forwards. + // Interceptor is not required. Release off-chain held forwards. log.Infof("Interceptor disconnected, resolving held packets") - s.heldHtlcSet.popAll(func(fwd InterceptedForward) { - err := fwd.Resume() - if err != nil { - log.Errorf("Failed to resume hold forward %v", err) - } - }) + errs := s.heldHtlcSet.releaseAllOffChainHeld() + for _, releaseErr := range errs { + log.Errorf("Failed to resume hold forward %v: %v", + releaseErr.key, releaseErr.err) + } } // resolve processes a HTLC given the resolution type specified by the // intercepting client. func (s *InterceptableSwitch) resolve(res *FwdResolution) error { - intercepted, err := s.heldHtlcSet.pop(res.Key) - if err != nil { - return err - } - - switch res.Action { - case FwdActionResume: - return intercepted.Resume() - - case FwdActionResumeModified: - return intercepted.ResumeModified( - res.InAmountMsat, res.OutAmountMsat, - res.OutWireCustomRecords, - ) - - case FwdActionSettle: - return intercepted.Settle(res.Preimage) - - case FwdActionFail: - if len(res.FailureMessage) > 0 { - return intercepted.Fail(res.FailureMessage) - } - - return intercepted.FailWithCode(res.FailureCode) - - default: - return fmt.Errorf("unrecognized action %v", res.Action) - } + return s.heldHtlcSet.resolve(res) } // Resolve resolves an intercepted packet. @@ -487,12 +467,38 @@ func (s *InterceptableSwitch) ForwardPackets(linkQuit <-chan struct{}, return nil } -// ForwardPacket forwards a single htlc to the external interceptor. +// ForwardPacket records a single on-chain HTLC for interception. It returns +// once the switch run loop has accepted or rejected the held entry. func (s *InterceptableSwitch) ForwardPacket( fwd InterceptedForward) error { + errChan := make(chan error, 1) select { - case s.onchainIntercepted <- fwd: + case s.onchainIntercepted <- &onchainInterceptRequest{ + fwd: fwd, + errChan: errChan, + }: + + case <-s.quit: + return errors.New("interceptable switch quit") + } + + select { + case err := <-errChan: + return err + + case <-s.quit: + return errors.New("interceptable switch quit") + } +} + +// RemoveOnChainIntercept removes an on-chain intercepted forward from the held +// set once its contractcourt resolver has finished. +func (s *InterceptableSwitch) RemoveOnChainIntercept( + key models.CircuitKey) error { + + select { + case s.onchainInterceptDone <- key: case <-s.quit: return errors.New("interceptable switch quit") @@ -542,15 +548,16 @@ func (s *InterceptableSwitch) interceptForward(packet *htlcPacket, return true, nil } - return s.forward(intercepted, isReplay) + return s.forwardOffChain(intercepted, isReplay) default: return false, nil } } -// forward records the intercepted htlc and forwards it to the interceptor. -func (s *InterceptableSwitch) forward( +// forwardOffChain records an off-chain intercepted htlc and forwards it to the +// interceptor if needed. +func (s *InterceptableSwitch) forwardOffChain( fwd InterceptedForward, isReplay bool) (bool, error) { inKey := fwd.Packet().IncomingCircuit @@ -585,16 +592,16 @@ func (s *InterceptableSwitch) forward( // This packet is a replay. It is not safe to fail back, because the // interceptor may still signal otherwise upon reconnect. Keep the // packet in the queue until then. - if err := s.heldHtlcSet.push(inKey, fwd); err != nil { + if err := s.heldHtlcSet.addOffChain(fwd); err != nil { return false, err } return true, nil } - // There is an interceptor registered. We can forward the packet right now. - // Hold it in the queue too to track what is outstanding. - if err := s.heldHtlcSet.push(inKey, fwd); err != nil { + // There is an interceptor registered. We can notify it right now. Hold + // the packet in the queue too to track what is outstanding. + if err := s.heldHtlcSet.addOffChain(fwd); err != nil { return false, err } @@ -603,6 +610,57 @@ func (s *InterceptableSwitch) forward( return true, nil } +// interceptOnChain records an on-chain intercepted htlc. This doesn't resume or +// forward the htlc through the link. If this HTLC is not already held on-chain, +// the interceptor is notified so the client can settle it. If it is currently +// held off-chain, the stored entry is replaced and the client is notified again +// with the on-chain deadline and settle-only semantics. +func (s *InterceptableSwitch) interceptOnChain(fwd InterceptedForward) error { + notify, err := s.holdOnChain(fwd) + if err != nil { + return err + } + + if s.interceptor != nil && notify { + s.sendForward(fwd) + } + + return nil +} + +// holdOnChain records an on-chain intercepted HTLC and reports whether it +// should be offered to the external interceptor. +func (s *InterceptableSwitch) holdOnChain( + fwd InterceptedForward) (bool, error) { + + if fwd == nil { + return false, errNilHeldForward + } + + inKey := fwd.Packet().IncomingCircuit + + // An already on-chain held HTLC has already been offered with its + // on-chain deadline. Treat duplicate contractcourt offers as no-ops to + // avoid re-notifying the interceptor for the same on-chain state. + if _, ok := s.heldHtlcSet.set[inKey].(*onChainHeld); ok { + return false, nil + } + + if err := s.heldHtlcSet.addOnChain(fwd); err != nil { + return false, err + } + + return true, nil +} + +// removeOnChainIntercept removes an on-chain held HTLC after contractcourt no +// longer needs the interceptor replay handle. +func (s *InterceptableSwitch) removeOnChainIntercept(key models.CircuitKey) { + if s.heldHtlcSet.removeOnChainHeld(key) { + log.Debugf("Removed on-chain held htlc %v", key) + } +} + // handleExpired checks that the htlc isn't too close to the channel // force-close broadcast height. If it is, it is cancelled back. func (s *InterceptableSwitch) handleExpired(fwd *interceptedForward) ( @@ -654,8 +712,10 @@ func (f *interceptedForward) Packet() InterceptedPacket { IncomingExpiry: f.packet.incomingTimeout, InOnionCustomRecords: f.packet.inOnionCustomRecords, OnionBlob: f.htlc.OnionBlob, - AutoFailHeight: f.autoFailHeight, - InWireCustomRecords: f.packet.inWireCustomRecords, + Deadline: fn.NewLeft[ + OffChainAutoFailHeight, OnChainSettleDeadline, + ](OffChainAutoFailHeight(f.autoFailHeight)), + InWireCustomRecords: f.packet.inWireCustomRecords, } } diff --git a/htlcswitch/interfaces.go b/htlcswitch/interfaces.go index 4739afff6..6a56b181e 100644 --- a/htlcswitch/interfaces.go +++ b/htlcswitch/interfaces.go @@ -419,9 +419,34 @@ type InterceptedPacket struct { // were defined by the peer that forwarded this HTLC to us. InWireCustomRecords lnwire.CustomRecords - // AutoFailHeight is the block height at which this intercept will be - // failed back automatically. - AutoFailHeight int32 + // Deadline describes how long this intercepted HTLC remains actionable. + // Off-chain forwards are auto-failed at this height, while on-chain + // forwards can be settled until this height. + Deadline fn.Either[OffChainAutoFailHeight, OnChainSettleDeadline] +} + +// OffChainAutoFailHeight is the block height at which an off-chain intercepted +// HTLC will be failed back automatically to prevent the incoming channel from +// force-closing. +type OffChainAutoFailHeight int32 + +// OnChainSettleDeadline is the block height until which an on-chain +// intercepted HTLC can be settled before the timeout path becomes available. +type OnChainSettleDeadline int32 + +// AutoFailHeight returns the legacy RPC auto_fail_height projection for an +// intercepted packet. For on-chain packets, the value is the settlement +// deadline exposed through the existing RPC field for compatibility. +func (p InterceptedPacket) AutoFailHeight() int32 { + return fn.ElimEither( + p.Deadline, + func(h OffChainAutoFailHeight) int32 { + return int32(h) + }, + func(d OnChainSettleDeadline) int32 { + return int32(d) + }, + ) } // InterceptedForward is passed to the ForwardInterceptor for every forwarded diff --git a/htlcswitch/switch_test.go b/htlcswitch/switch_test.go index 884e9f368..13563916e 100644 --- a/htlcswitch/switch_test.go +++ b/htlcswitch/switch_test.go @@ -4216,7 +4216,7 @@ func TestInterceptableSwitchWatchDog(t *testing.T) { require.Equal(t, int32(packet.incomingTimeout-c.cltvRejectDelta), - intercepted.AutoFailHeight, + intercepted.AutoFailHeight(), ) // Htlc expires before a resolution from the interceptor. diff --git a/lnrpc/routerrpc/forward_interceptor.go b/lnrpc/routerrpc/forward_interceptor.go index 6d6b3cf18..61adf8f2b 100644 --- a/lnrpc/routerrpc/forward_interceptor.go +++ b/lnrpc/routerrpc/forward_interceptor.go @@ -96,7 +96,7 @@ func (r *forwardInterceptor) onIntercept( IncomingExpiry: htlc.IncomingExpiry, CustomRecords: htlc.InOnionCustomRecords, OnionBlob: htlc.OnionBlob[:], - AutoFailHeight: htlc.AutoFailHeight, + AutoFailHeight: htlc.AutoFailHeight(), InWireCustomRecords: htlc.InWireCustomRecords, } diff --git a/witness_beacon.go b/witness_beacon.go index eba0bcad6..72261829c 100644 --- a/witness_beacon.go +++ b/witness_beacon.go @@ -6,6 +6,7 @@ import ( "github.com/lightningnetwork/lnd/channeldb" "github.com/lightningnetwork/lnd/contractcourt" + "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/graph/db/models" "github.com/lightningnetwork/lnd/htlcswitch" "github.com/lightningnetwork/lnd/htlcswitch/hop" @@ -106,6 +107,12 @@ func (p *preimageBeacon) SubscribeUpdates( OutgoingAmount: payload.FwdInfo.AmountToForward, InOnionCustomRecords: payload.CustomRecords(), InWireCustomRecords: htlc.CustomRecords, + // Keep the on-chain intercept available to the + // interceptor until the HTLC expires on chain. + Deadline: fn.NewRight[ + htlcswitch.OffChainAutoFailHeight, + htlcswitch.OnChainSettleDeadline, + ](htlcswitch.OnChainSettleDeadline(htlc.RefundTimeout)), } copy(packet.OnionBlob[:], nextHopOnionBlob) From 7a42b56cbff4e2feb71dbc795128eacc71266ba8 Mon Sep 17 00:00:00 2001 From: ziggie Date: Mon, 22 Jun 2026 08:35:20 -0300 Subject: [PATCH 098/134] witnessbeacon: avoid interceptor deadlock Release the preimage beacon lock before invoking the on-chain interceptor. The interceptor path can block on the htlcswitch event loop, while resolution of another held on-chain HTLC can call back into the beacon to add a preimage. If interceptor delivery fails after the subscriber was registered, cancel the subscription before returning the error. On-chain held entries are replay handles for the interceptor while contractcourt waits for a preimage or on-chain expiry. Once the resolver tears down, keeping the handle until the refund timeout can replay a stale HTLC to a reconnecting interceptor. Thread a dedicated cleanup signal from the witness subscription cancel path back through the interceptable switch event loop. The held set only removes on-chain entries for that signal, leaving off-chain entries under the link flow lifecycle. (cherry picked from commit 98da7b4a56a75ba2dbf47391d43229fd9696e98a) --- server.go | 1 + witness_beacon.go | 45 +++++++++++++++++++++------------ witness_beacon_test.go | 56 ++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 84 insertions(+), 18 deletions(-) diff --git a/server.go b/server.go index f0b4fec3b..55c7639fc 100644 --- a/server.go +++ b/server.go @@ -822,6 +822,7 @@ func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr, s.witnessBeacon = newPreimageBeacon( dbs.ChanStateDB.NewWitnessCache(), s.interceptableSwitch.ForwardPacket, + s.interceptableSwitch.RemoveOnChainIntercept, ) chanStatusMgrCfg := &netann.ChanStatusConfig{ diff --git a/witness_beacon.go b/witness_beacon.go index 72261829c..68c096a85 100644 --- a/witness_beacon.go +++ b/witness_beacon.go @@ -45,15 +45,19 @@ type preimageBeacon struct { subscribers map[uint64]*preimageSubscriber interceptor func(htlcswitch.InterceptedForward) error + + cancelInterceptor func(models.CircuitKey) error } func newPreimageBeacon(wCache witnessCache, - interceptor func(htlcswitch.InterceptedForward) error) *preimageBeacon { + interceptor func(htlcswitch.InterceptedForward) error, + cancelInterceptor func(models.CircuitKey) error) *preimageBeacon { return &preimageBeacon{ - wCache: wCache, - interceptor: interceptor, - subscribers: make(map[uint64]*preimageSubscriber), + wCache: wCache, + interceptor: interceptor, + cancelInterceptor: cancelInterceptor, + subscribers: make(map[uint64]*preimageSubscriber), } } @@ -65,43 +69,50 @@ func (p *preimageBeacon) SubscribeUpdates( nextHopOnionBlob []byte) (*contractcourt.WitnessSubscription, error) { p.Lock() - defer p.Unlock() - clientID := p.clientCounter client := &preimageSubscriber{ updateChan: make(chan lntypes.Preimage, 10), quit: make(chan struct{}), } - p.subscribers[p.clientCounter] = client + p.subscribers[clientID] = client p.clientCounter++ + p.Unlock() srvrLog.Debugf("Creating new witness beacon subscriber, id=%v", - p.clientCounter) + clientID) + + inKey := models.CircuitKey{ + ChanID: chanID, + HtlcID: htlc.HtlcIndex, + } sub := &contractcourt.WitnessSubscription{ WitnessUpdates: client.updateChan, CancelSubscription: func() { p.Lock() - defer p.Unlock() delete(p.subscribers, clientID) close(client.quit) + p.Unlock() + + err := p.cancelInterceptor(inKey) + if err != nil { + srvrLog.Errorf("Cannot remove on-chain "+ + "intercept %v: %v", inKey, err) + } }, } // Notify the htlc interceptor. There may be a client connected // and willing to supply a preimage. packet := &htlcswitch.InterceptedPacket{ - Hash: htlc.RHash, - IncomingExpiry: htlc.RefundTimeout, - IncomingAmount: htlc.Amt, - IncomingCircuit: models.CircuitKey{ - ChanID: chanID, - HtlcID: htlc.HtlcIndex, - }, + Hash: htlc.RHash, + IncomingExpiry: htlc.RefundTimeout, + IncomingAmount: htlc.Amt, + IncomingCircuit: inKey, OutgoingChanID: payload.FwdInfo.NextHop, OutgoingExpiry: payload.FwdInfo.OutgoingCLTV, OutgoingAmount: payload.FwdInfo.AmountToForward, @@ -120,6 +131,8 @@ func (p *preimageBeacon) SubscribeUpdates( err := p.interceptor(fwd) if err != nil { + sub.CancelSubscription() + return nil, err } diff --git a/witness_beacon_test.go b/witness_beacon_test.go index d98c276f5..1edbada93 100644 --- a/witness_beacon_test.go +++ b/witness_beacon_test.go @@ -1,9 +1,11 @@ package lnd import ( + "errors" "testing" "github.com/lightningnetwork/lnd/channeldb" + "github.com/lightningnetwork/lnd/graph/db/models" "github.com/lightningnetwork/lnd/htlcswitch" "github.com/lightningnetwork/lnd/htlcswitch/hop" "github.com/lightningnetwork/lnd/lntypes" @@ -20,9 +22,15 @@ func TestWitnessBeaconIntercept(t *testing.T) { return nil } + var canceledKey models.CircuitKey + cancelInterceptor := func(key models.CircuitKey) error { + canceledKey = key + + return nil + } p := newPreimageBeacon( - &mockWitnessCache{}, interceptor, + &mockWitnessCache{}, interceptor, cancelInterceptor, ) preimage := lntypes.Preimage{1, 2, 3} @@ -37,12 +45,56 @@ func TestWitnessBeaconIntercept(t *testing.T) { []byte{2}, ) require.NoError(t, err) - t.Cleanup(subscription.CancelSubscription) require.NoError(t, interceptedFwd.Settle(preimage)) update := <-subscription.WitnessUpdates require.Equal(t, preimage, update) + + subscription.CancelSubscription() + require.Equal(t, interceptedFwd.Packet().IncomingCircuit, canceledKey) +} + +// TestWitnessBeaconInterceptErrorCancels tests that a failed interceptor offer +// tears down the witness subscription and on-chain intercept handle. +func TestWitnessBeaconInterceptErrorCancels(t *testing.T) { + errInterceptor := errors.New("interceptor error") + + interceptor := func(htlcswitch.InterceptedForward) error { + return errInterceptor + } + + var canceledKey models.CircuitKey + cancelInterceptor := func(key models.CircuitKey) error { + canceledKey = key + + return nil + } + + p := newPreimageBeacon( + &mockWitnessCache{}, interceptor, cancelInterceptor, + ) + + chanID := lnwire.NewShortChanIDFromInt(1) + htlc := &channeldb.HTLC{ + HtlcIndex: 2, + RHash: lntypes.Hash{3}, + } + + subscription, err := p.SubscribeUpdates( + chanID, htlc, &hop.Payload{}, []byte{2}, + ) + require.ErrorIs(t, err, errInterceptor) + require.Nil(t, subscription) + + require.Equal(t, models.CircuitKey{ + ChanID: chanID, + HtlcID: htlc.HtlcIndex, + }, canceledKey) + + p.RLock() + require.Empty(t, p.subscribers) + p.RUnlock() } type mockWitnessCache struct { From bc8463a98fef60dcb805065f1665d42f65202ae0 Mon Sep 17 00:00:00 2001 From: ziggie Date: Mon, 22 Jun 2026 08:35:30 -0300 Subject: [PATCH 099/134] routerrpc: add clarifying docs for the intercepted forward routerrpc: document on-chain interceptor responses (cherry picked from commit 8909c2fbf5535b81ee18c4f4e7fe0160ca43e725) --- lnrpc/routerrpc/router.pb.go | 15 ++++++++++++++- lnrpc/routerrpc/router.proto | 15 ++++++++++++++- lnrpc/routerrpc/router.swagger.json | 8 ++++---- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/lnrpc/routerrpc/router.pb.go b/lnrpc/routerrpc/router.pb.go index a4497c2bb..2a7b2deae 100644 --- a/lnrpc/routerrpc/router.pb.go +++ b/lnrpc/routerrpc/router.pb.go @@ -3073,6 +3073,10 @@ type ForwardHtlcInterceptRequest struct { // The key of this forwarded htlc. It defines the incoming channel id and // the index in this channel. + // + // Interceptor clients should handle requests for the same circuit key + // idempotently. Requests may be replayed after reconnect, and an htlc that was + // previously offered off-chain may be offered again after it moves on-chain. IncomingCircuitKey *CircuitKey `protobuf:"bytes,1,opt,name=incoming_circuit_key,json=incomingCircuitKey,proto3" json:"incoming_circuit_key,omitempty"` // The incoming htlc amount. IncomingAmountMsat uint64 `protobuf:"varint,5,opt,name=incoming_amount_msat,json=incomingAmountMsat,proto3" json:"incoming_amount_msat,omitempty"` @@ -3095,7 +3099,8 @@ type ForwardHtlcInterceptRequest struct { // The onion blob for the next hop OnionBlob []byte `protobuf:"bytes,9,opt,name=onion_blob,json=onionBlob,proto3" json:"onion_blob,omitempty"` // The block height at which this htlc will be auto-failed to prevent the - // channel from force-closing. + // channel from force-closing. For on-chain htlcs, this field is the + // settlement deadline instead and no automatic fail-back is attempted. AutoFailHeight int32 `protobuf:"varint,10,opt,name=auto_fail_height,json=autoFailHeight,proto3" json:"auto_fail_height,omitempty"` // The custom records of the peer's incoming p2p wire message. InWireCustomRecords map[uint64][]byte `protobuf:"bytes,11,rep,name=in_wire_custom_records,json=inWireCustomRecords,proto3" json:"in_wire_custom_records,omitempty" protobuf_key:"varint,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` @@ -3218,6 +3223,14 @@ func (x *ForwardHtlcInterceptRequest) GetInWireCustomRecords() map[uint64][]byte // field modifications. // - `Reject`: Fail the htlc backwards. // - `Settle`: Settle this htlc with a given preimage. +// +// Once the incoming channel has force-closed and the HTLC is being resolved +// on-chain (see auto_fail_height), only `Settle` has any effect. The HTLC can no +// longer be resumed or failed back off-chain, so `Resume`, `ResumeModified`, and +// `Fail` return a stream-terminating error. The HTLC stays held until it is +// settled with a preimage, the on-chain resolver completes, or it expires +// on-chain. Clients should reconnect to receive any held HTLCs that remain +// unresolved. type ForwardHtlcInterceptResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache diff --git a/lnrpc/routerrpc/router.proto b/lnrpc/routerrpc/router.proto index 9e305e37e..8f5502675 100644 --- a/lnrpc/routerrpc/router.proto +++ b/lnrpc/routerrpc/router.proto @@ -981,6 +981,10 @@ message ForwardHtlcInterceptRequest { /* The key of this forwarded htlc. It defines the incoming channel id and the index in this channel. + + Interceptor clients should handle requests for the same circuit key + idempotently. Requests may be replayed after reconnect, and an htlc that was + previously offered off-chain may be offered again after it moves on-chain. */ CircuitKey incoming_circuit_key = 1; @@ -1015,7 +1019,8 @@ message ForwardHtlcInterceptRequest { bytes onion_blob = 9; // The block height at which this htlc will be auto-failed to prevent the - // channel from force-closing. + // channel from force-closing. For on-chain htlcs, this field is the + // settlement deadline instead and no automatic fail-back is attempted. int32 auto_fail_height = 10; // The custom records of the peer's incoming p2p wire message. @@ -1030,6 +1035,14 @@ forward. The caller can choose either to: field modifications. - `Reject`: Fail the htlc backwards. - `Settle`: Settle this htlc with a given preimage. + +Once the incoming channel has force-closed and the HTLC is being resolved +on-chain (see auto_fail_height), only `Settle` has any effect. The HTLC can no +longer be resumed or failed back off-chain, so `Resume`, `ResumeModified`, and +`Fail` return a stream-terminating error. The HTLC stays held until it is +settled with a preimage, the on-chain resolver completes, or it expires +on-chain. Clients should reconnect to receive any held HTLCs that remain +unresolved. */ message ForwardHtlcInterceptResponse { /** diff --git a/lnrpc/routerrpc/router.swagger.json b/lnrpc/routerrpc/router.swagger.json index 996ead616..4fdf61663 100644 --- a/lnrpc/routerrpc/router.swagger.json +++ b/lnrpc/routerrpc/router.swagger.json @@ -78,7 +78,7 @@ "parameters": [ { "name": "body", - "description": "*\nForwardHtlcInterceptResponse enables the caller to resolve a previously hold\nforward. The caller can choose either to:\n- `Resume`: Execute the default behavior (usually forward).\n- `ResumeModified`: Execute the default behavior (usually forward) with HTLC\nfield modifications.\n- `Reject`: Fail the htlc backwards.\n- `Settle`: Settle this htlc with a given preimage. (streaming inputs)", + "description": "*\nForwardHtlcInterceptResponse enables the caller to resolve a previously hold\nforward. The caller can choose either to:\n- `Resume`: Execute the default behavior (usually forward).\n- `ResumeModified`: Execute the default behavior (usually forward) with HTLC\nfield modifications.\n- `Reject`: Fail the htlc backwards.\n- `Settle`: Settle this htlc with a given preimage.\n\nOnce the incoming channel has force-closed and the HTLC is being resolved\non-chain (see auto_fail_height), only `Settle` has any effect. The HTLC can no\nlonger be resumed or failed back off-chain, so `Resume`, `ResumeModified`, and\n`Fail` return a stream-terminating error. The HTLC stays held until it is\nsettled with a preimage, the on-chain resolver completes, or it expires\non-chain. Clients should reconnect to receive any held HTLCs that remain\nunresolved. (streaming inputs)", "in": "body", "required": true, "schema": { @@ -1481,7 +1481,7 @@ "properties": { "incoming_circuit_key": { "$ref": "#/definitions/routerrpcCircuitKey", - "description": "The key of this forwarded htlc. It defines the incoming channel id and\nthe index in this channel." + "description": "The key of this forwarded htlc. It defines the incoming channel id and\nthe index in this channel.\n\nInterceptor clients should handle requests for the same circuit key\nidempotently. Requests may be replayed after reconnect, and an htlc that was\npreviously offered off-chain may be offered again after it moves on-chain." }, "incoming_amount_msat": { "type": "string", @@ -1529,7 +1529,7 @@ "auto_fail_height": { "type": "integer", "format": "int32", - "description": "The block height at which this htlc will be auto-failed to prevent the\nchannel from force-closing." + "description": "The block height at which this htlc will be auto-failed to prevent the\nchannel from force-closing. For on-chain htlcs, this field is the\nsettlement deadline instead and no automatic fail-back is attempted." }, "in_wire_custom_records": { "type": "object", @@ -1585,7 +1585,7 @@ "description": "Any custom records that should be set on the p2p wire message message of\nthe resumed HTLC. This field is ignored if the action is not\nRESUME_MODIFIED.\n\nThis map will merge with the existing set of custom records (if any),\nreplacing any conflicting types. Note that there currently is no support\nfor deleting existing custom records (they can only be replaced)." } }, - "description": "*\nForwardHtlcInterceptResponse enables the caller to resolve a previously hold\nforward. The caller can choose either to:\n- `Resume`: Execute the default behavior (usually forward).\n- `ResumeModified`: Execute the default behavior (usually forward) with HTLC\nfield modifications.\n- `Reject`: Fail the htlc backwards.\n- `Settle`: Settle this htlc with a given preimage." + "description": "*\nForwardHtlcInterceptResponse enables the caller to resolve a previously hold\nforward. The caller can choose either to:\n- `Resume`: Execute the default behavior (usually forward).\n- `ResumeModified`: Execute the default behavior (usually forward) with HTLC\nfield modifications.\n- `Reject`: Fail the htlc backwards.\n- `Settle`: Settle this htlc with a given preimage.\n\nOnce the incoming channel has force-closed and the HTLC is being resolved\non-chain (see auto_fail_height), only `Settle` has any effect. The HTLC can no\nlonger be resumed or failed back off-chain, so `Resume`, `ResumeModified`, and\n`Fail` return a stream-terminating error. The HTLC stays held until it is\nsettled with a preimage, the on-chain resolver completes, or it expires\non-chain. Clients should reconnect to receive any held HTLCs that remain\nunresolved." }, "routerrpcGetMissionControlConfigResponse": { "type": "object", From cf20de9bfbbc9391999a5c459cd62867aa64a774 Mon Sep 17 00:00:00 2001 From: ziggie Date: Thu, 25 Jun 2026 13:04:09 -0300 Subject: [PATCH 100/134] docs: add v0.20.2 release note (cherry picked from commit 9c5f32a2ec8eab0760a46c3d0357d4127794425f) --- docs/release-notes/release-notes-0.20.2.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/release-notes/release-notes-0.20.2.md b/docs/release-notes/release-notes-0.20.2.md index e8dd2e318..f7463c95f 100644 --- a/docs/release-notes/release-notes-0.20.2.md +++ b/docs/release-notes/release-notes-0.20.2.md @@ -27,6 +27,15 @@ non-SRV record. Non-SRV records are now skipped, and an empty `LookupHost` result for the shim no longer triggers an out-of-bounds index. +- [Fixed on-chain forward interceptor + settlement](https://github.com/lightningnetwork/lnd/pull/10895) after the + incoming channel force closes. Held forwards are now tracked as off-chain or + on-chain entries, allowing an on-chain re-offer to replace the old off-chain + hold so settlement reaches the witness beacon. Go callers of the exported + `htlcswitch.InterceptedPacket` type should use the new `Deadline` field to + distinguish off-chain auto-fail heights from on-chain settlement deadlines, + or `AutoFailHeight()` if they only need the legacy flattened value. + # New Features ## Functional Enhancements From f4fa341e023e2bca14e11ece5724c4e12bd7ca14 Mon Sep 17 00:00:00 2001 From: ziggie Date: Mon, 29 Jun 2026 07:51:35 -0300 Subject: [PATCH 101/134] build: bump version to 0.20.2-beta.rc1 --- build/version.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build/version.go b/build/version.go index 23245596a..86c73c942 100644 --- a/build/version.go +++ b/build/version.go @@ -47,11 +47,11 @@ const ( AppMinor uint = 20 // AppPatch defines the application patch for this binary. - AppPatch uint = 01 + AppPatch uint = 02 // AppPreRelease MUST only contain characters from semanticAlphabet per // the semantic versioning spec. - AppPreRelease = "beta" + AppPreRelease = "beta.rc1" ) func init() { From 5e26b56067b50e08493bc338280e7cf7e0691738 Mon Sep 17 00:00:00 2001 From: ziggie Date: Tue, 7 Jul 2026 07:08:59 -0300 Subject: [PATCH 102/134] build: bump version to v0.20.2 --- build/version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/version.go b/build/version.go index 86c73c942..0d7b69633 100644 --- a/build/version.go +++ b/build/version.go @@ -51,7 +51,7 @@ const ( // AppPreRelease MUST only contain characters from semanticAlphabet per // the semantic versioning spec. - AppPreRelease = "beta.rc1" + AppPreRelease = "beta" ) func init() { From 86eeacc3aebd15600db00457d36ee5bebf198630 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Thu, 23 Jul 2026 13:52:45 +0000 Subject: [PATCH 103/134] htlcswitch: key the aux traffic shaper on the evaluated channel During non-strict forwarding, handlePacketAdd evaluates every candidate channel to the next peer and calls CheckHtlcForward with the sender-requested outgoing SCID (originalOutgoingChanID) for each candidate. That SCID flowed through canSendHtlc into AuxTrafficShaper.ShouldHandleTraffic, so a channel-keyed shaper was asked about the requested channel rather than the candidate actually being evaluated. With parallel channels to a peer this inspects the wrong channel. Key the shaper on l.ShortChanID() (the channel under evaluation) instead. originalScid is retained solely for createFailureWithUpdate / FailAliasUpdate, so the alias-aware channel_update returned to the sender is unchanged and the real SCID handed to the shaper never leaks onto the wire. (cherry picked from commit b166780015ec425b74166cd2d11105a9312c24b8) --- htlcswitch/link.go | 5 +- htlcswitch/link_test.go | 129 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 1 deletion(-) diff --git a/htlcswitch/link.go b/htlcswitch/link.go index 056403cb3..f966cd3e2 100644 --- a/htlcswitch/link.go +++ b/htlcswitch/link.go @@ -2635,7 +2635,10 @@ func (l *channelLink) canSendHtlc(policy models.ForwardingPolicy, htlcBlob = fn.Some(blob) } - return l.AuxBandwidth(amt, originalScid, htlcBlob, ts) + // Check if this link can handle the traffic. + return l.AuxBandwidth( + amt, l.ShortChanID(), htlcBlob, ts, + ) }, ).Unpack() if externalErr != nil { diff --git a/htlcswitch/link_test.go b/htlcswitch/link_test.go index a64942d5c..991819b77 100644 --- a/htlcswitch/link_test.go +++ b/htlcswitch/link_test.go @@ -40,6 +40,7 @@ import ( "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/ticker" + "github.com/lightningnetwork/lnd/tlv" "github.com/stretchr/testify/require" ) @@ -6394,6 +6395,134 @@ func TestCheckHtlcForward(t *testing.T) { }) } +// recordingAuxShaper is a minimal AuxTrafficShaper that records the channel id +// it is asked about and declines to handle the traffic, so the normal +// forwarding path proceeds. Only the methods reached by CheckHtlcForward are +// implemented; the rest are inherited from the embedded (nil) interface and +// must never be called. +type recordingAuxShaper struct { + AuxTrafficShaper + + gotCID lnwire.ShortChannelID +} + +// ShouldHandleTraffic records the short channel ID passed to the shaper. +func (a *recordingAuxShaper) ShouldHandleTraffic(cid lnwire.ShortChannelID, + _, _ fn.Option[tlv.Blob]) (bool, error) { + + a.gotCID = cid + + return false, nil +} + +// IsCustomHTLC returns false as recordingAuxShaper handles standard HTLCs. +func (a *recordingAuxShaper) IsCustomHTLC(_ lnwire.CustomRecords) bool { + return false +} + +// TestCheckHtlcForwardAuxShaperChannel asserts that during non-strict +// forwarding the aux traffic shaper is keyed on the channel actually being +// evaluated (the link's own SCID), not the sender-requested SCID, which fixes +// both the node-ID/blinded path (where no SCID is requested) and pre-existing +// parallel-channel forwarding. It also asserts the real SCID handed to the +// shaper never leaks into the sender-facing channel_update, which continues to +// reference the requested (alias) SCID. +func TestCheckHtlcForwardAuxShaperChannel(t *testing.T) { + t.Parallel() + + const ( + chanScid = 42 + requestedScid = 99 + ) + + fetchLastChannelUpdate := func(lnwire.ShortChannelID) ( + *lnwire.ChannelUpdate1, error) { + + return &lnwire.ChannelUpdate1{}, nil + } + + // Record the SCID used to build the returned channel_update on failure. + var updateScid lnwire.ShortChannelID + failAliasUpdate := func(sid lnwire.ShortChannelID, + incoming bool) *lnwire.ChannelUpdate1 { + + updateScid = sid + + return &lnwire.ChannelUpdate1{ + ShortChannelID: sid, + } + } + + testChannel, _, err := createTestChannel( + t, alicePrivKey, bobPrivKey, 100000, 100000, 1000, 1000, + lnwire.NewShortChanIDFromInt(chanScid), + ) + require.NoError(t, err) + + shaper := &recordingAuxShaper{} + link := channelLink{ + cfg: ChannelLinkConfig{ + FwrdingPolicy: models.ForwardingPolicy{ + TimeLockDelta: 20, + MinHTLCOut: 500, + MaxHTLC: 1000, + BaseFee: 10, + }, + FetchLastChannelUpdate: fetchLastChannelUpdate, + MaxOutgoingCltvExpiry: DefaultMaxOutgoingCltvExpiry, + HtlcNotifier: &mockHTLCNotifier{}, + }, + log: log, + channel: testChannel.channel, + } + link.cfg.AuxTrafficShaper = fn.Some[AuxTrafficShaper](shaper) + link.attachFailAliasUpdate(failAliasUpdate) + + require.Equal( + t, lnwire.NewShortChanIDFromInt(chanScid), link.ShortChanID(), + ) + + var hash [32]byte + requested := lnwire.NewShortChanIDFromInt(requestedScid) + + // A satisfiable forward: the shaper must be queried about the channel + // being evaluated (the link's own SCID), not the requested SCID. + result := link.CheckHtlcForward( + hash, 1500, 1000, 200, 150, models.InboundFee{}, 0, requested, + nil, + ) + require.Nil(t, result, "expected policy to be satisfied") + require.Equal( + t, link.ShortChanID(), shaper.gotCID, + "aux shaper must be keyed on the evaluated channel", + ) + require.NotEqual( + t, requested, shaper.gotCID, + "aux shaper must not be keyed on the requested SCID", + ) + + // A failing forward: the returned channel_update must reference the + // requested (alias) SCID, never the real channel SCID handed to the + // shaper. + result = link.CheckHtlcForward( + hash, 100, 50, 200, 150, models.InboundFee{}, 0, requested, nil, + ) + require.NotNil(t, result) + require.Equal( + t, requested, updateScid, + "channel_update must reference the requested SCID, not the "+ + "real channel SCID", + ) + + wireErr := result.WireMessage() + failAmt, ok := wireErr.(*lnwire.FailAmountBelowMinimum) + require.True(t, ok, "expected FailAmountBelowMinimum failure") + require.Equal( + t, requested, failAmt.Update.ShortChannelID, + "failure update must carry the requested SCID", + ) +} + // TestChannelLinkCanceledInvoice in this test checks the interaction // between Alice and Bob for a canceled invoice. func TestChannelLinkCanceledInvoice(t *testing.T) { From 9e1f98ed5392d86b7f6b7d724eb46a501746db16 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Fri, 24 Jul 2026 07:53:34 +0000 Subject: [PATCH 104/134] lnrpc/routerrpc: add outgoing_node_id to HTLC intercept request A blinded route may identify the next hop by node ID (next_node_id) rather than by channel, in which case there is no sender-specified outgoing channel to report to an HTLC interceptor. Add an outgoing_node_id field to ForwardHtlcInterceptRequest to carry the next hop's public key for these forwards, and document that outgoing_requested_chan_id then holds a reserved sentinel value so that clients switching on a zero channel ID to detect the exit hop do not misclassify the forward as a final receive. This commit only adds the schema and regenerated stubs; the fields are populated by later commits. (cherry picked from commit 14640a501658cd18853b14a2f2d2ff86b56dfbf8) --- lnrpc/routerrpc/router.pb.go | 526 +++++++++++++++------------- lnrpc/routerrpc/router.proto | 16 +- lnrpc/routerrpc/router.swagger.json | 7 +- 3 files changed, 296 insertions(+), 253 deletions(-) diff --git a/lnrpc/routerrpc/router.pb.go b/lnrpc/routerrpc/router.pb.go index 2a7b2deae..1496cdd14 100644 --- a/lnrpc/routerrpc/router.pb.go +++ b/lnrpc/routerrpc/router.pb.go @@ -3088,7 +3088,8 @@ type ForwardHtlcInterceptRequest struct { // The requested outgoing channel id for this forwarded htlc. Because of // non-strict forwarding, this isn't necessarily the channel over which the // packet will be forwarded eventually. A different channel to the same peer - // may be selected as well. + // may be selected as well. This is set to a sentinel value (all bits set) + // if the outgoing_requested_node_id is specified for blinded routes. OutgoingRequestedChanId uint64 `protobuf:"varint,7,opt,name=outgoing_requested_chan_id,json=outgoingRequestedChanId,proto3" json:"outgoing_requested_chan_id,omitempty"` // The outgoing htlc amount. OutgoingAmountMsat uint64 `protobuf:"varint,3,opt,name=outgoing_amount_msat,json=outgoingAmountMsat,proto3" json:"outgoing_amount_msat,omitempty"` @@ -3104,6 +3105,19 @@ type ForwardHtlcInterceptRequest struct { AutoFailHeight int32 `protobuf:"varint,10,opt,name=auto_fail_height,json=autoFailHeight,proto3" json:"auto_fail_height,omitempty"` // The custom records of the peer's incoming p2p wire message. InWireCustomRecords map[uint64][]byte `protobuf:"bytes,11,rep,name=in_wire_custom_records,json=inWireCustomRecords,proto3" json:"in_wire_custom_records,omitempty" protobuf_key:"varint,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` + // The requested outgoing node for a blinded forward. When non-empty, this + // field contains exactly one 33-byte compressed public key and + // outgoing_requested_chan_id is set to 18446744073709551615 + // (0xffffffffffffffff). Clients MUST NOT interpret that value as an actual + // channel ID; the presence of this field identifies a node-addressed + // forward. + // + // The possible next-hop representations are: + // + // node ID empty, channel ID 0: final receive; + // node ID empty, ordinary channel ID: channel-addressed forward; + // node ID present, channel ID MaxUint64: node-addressed forward. + OutgoingRequestedNodeId []byte `protobuf:"bytes,12,opt,name=outgoing_requested_node_id,json=outgoingRequestedNodeId,proto3" json:"outgoing_requested_node_id,omitempty"` } func (x *ForwardHtlcInterceptRequest) Reset() { @@ -3215,6 +3229,13 @@ func (x *ForwardHtlcInterceptRequest) GetInWireCustomRecords() map[uint64][]byte return nil } +func (x *ForwardHtlcInterceptRequest) GetOutgoingRequestedNodeId() []byte { + if x != nil { + return x.OutgoingRequestedNodeId + } + return nil +} + // * // ForwardHtlcInterceptResponse enables the caller to resolve a previously hold // forward. The caller can choose either to: @@ -4132,7 +4153,7 @@ var file_routerrpc_router_proto_rawDesc = []byte{ 0x0a, 0x07, 0x63, 0x68, 0x61, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x63, 0x68, 0x61, 0x6e, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x68, 0x74, 0x6c, 0x63, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x68, 0x74, 0x6c, 0x63, 0x49, 0x64, - 0x22, 0xa7, 0x06, 0x0a, 0x1b, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x48, 0x74, 0x6c, 0x63, + 0x22, 0xe4, 0x06, 0x0a, 0x1b, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x48, 0x74, 0x6c, 0x63, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x47, 0x0a, 0x14, 0x69, 0x6e, 0x63, 0x6f, 0x6d, 0x69, 0x6e, 0x67, 0x5f, 0x63, 0x69, 0x72, 0x63, 0x75, 0x69, 0x74, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, @@ -4174,257 +4195,260 @@ var file_routerrpc_router_proto_rawDesc = []byte{ 0x73, 0x74, 0x2e, 0x49, 0x6e, 0x57, 0x69, 0x72, 0x65, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x13, 0x69, 0x6e, 0x57, 0x69, 0x72, 0x65, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, - 0x1a, 0x40, 0x0a, 0x12, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, - 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x04, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, - 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, - 0x38, 0x01, 0x1a, 0x46, 0x0a, 0x18, 0x49, 0x6e, 0x57, 0x69, 0x72, 0x65, 0x43, 0x75, 0x73, 0x74, - 0x6f, 0x6d, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, - 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x03, 0x6b, 0x65, 0x79, - 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, - 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xb9, 0x04, 0x0a, 0x1c, 0x46, - 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x48, 0x74, 0x6c, 0x63, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, - 0x65, 0x70, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x47, 0x0a, 0x14, 0x69, - 0x6e, 0x63, 0x6f, 0x6d, 0x69, 0x6e, 0x67, 0x5f, 0x63, 0x69, 0x72, 0x63, 0x75, 0x69, 0x74, 0x5f, - 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x6f, 0x75, 0x74, - 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x43, 0x69, 0x72, 0x63, 0x75, 0x69, 0x74, 0x4b, 0x65, 0x79, - 0x52, 0x12, 0x69, 0x6e, 0x63, 0x6f, 0x6d, 0x69, 0x6e, 0x67, 0x43, 0x69, 0x72, 0x63, 0x75, 0x69, - 0x74, 0x4b, 0x65, 0x79, 0x12, 0x3b, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x0e, 0x32, 0x23, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, - 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x76, 0x65, 0x48, 0x6f, 0x6c, 0x64, 0x46, 0x6f, 0x72, 0x77, - 0x61, 0x72, 0x64, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, - 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x65, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x0c, 0x52, 0x08, 0x70, 0x72, 0x65, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x27, 0x0a, - 0x0f, 0x66, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, - 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0e, 0x66, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x4d, - 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x3d, 0x0a, 0x0c, 0x66, 0x61, 0x69, 0x6c, 0x75, 0x72, - 0x65, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x6c, - 0x6e, 0x72, 0x70, 0x63, 0x2e, 0x46, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x2e, 0x46, 0x61, 0x69, - 0x6c, 0x75, 0x72, 0x65, 0x43, 0x6f, 0x64, 0x65, 0x52, 0x0b, 0x66, 0x61, 0x69, 0x6c, 0x75, 0x72, - 0x65, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x24, 0x0a, 0x0e, 0x69, 0x6e, 0x5f, 0x61, 0x6d, 0x6f, 0x75, - 0x6e, 0x74, 0x5f, 0x6d, 0x73, 0x61, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0c, 0x69, - 0x6e, 0x41, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x4d, 0x73, 0x61, 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x6f, - 0x75, 0x74, 0x5f, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x6d, 0x73, 0x61, 0x74, 0x18, 0x07, - 0x20, 0x01, 0x28, 0x04, 0x52, 0x0d, 0x6f, 0x75, 0x74, 0x41, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x4d, - 0x73, 0x61, 0x74, 0x12, 0x78, 0x0a, 0x17, 0x6f, 0x75, 0x74, 0x5f, 0x77, 0x69, 0x72, 0x65, 0x5f, - 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x72, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x18, 0x08, - 0x20, 0x03, 0x28, 0x0b, 0x32, 0x41, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, - 0x2e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x48, 0x74, 0x6c, 0x63, 0x49, 0x6e, 0x74, 0x65, - 0x72, 0x63, 0x65, 0x70, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x4f, 0x75, + 0x12, 0x3b, 0x0a, 0x1a, 0x6f, 0x75, 0x74, 0x67, 0x6f, 0x69, 0x6e, 0x67, 0x5f, 0x72, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x65, 0x64, 0x5f, 0x6e, 0x6f, 0x64, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x0c, + 0x20, 0x01, 0x28, 0x0c, 0x52, 0x17, 0x6f, 0x75, 0x74, 0x67, 0x6f, 0x69, 0x6e, 0x67, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x65, 0x64, 0x4e, 0x6f, 0x64, 0x65, 0x49, 0x64, 0x1a, 0x40, 0x0a, + 0x12, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x45, 0x6e, + 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, + 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, + 0x46, 0x0a, 0x18, 0x49, 0x6e, 0x57, 0x69, 0x72, 0x65, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x52, + 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, + 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, + 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, 0x61, + 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xb9, 0x04, 0x0a, 0x1c, 0x46, 0x6f, 0x72, 0x77, + 0x61, 0x72, 0x64, 0x48, 0x74, 0x6c, 0x63, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x47, 0x0a, 0x14, 0x69, 0x6e, 0x63, 0x6f, + 0x6d, 0x69, 0x6e, 0x67, 0x5f, 0x63, 0x69, 0x72, 0x63, 0x75, 0x69, 0x74, 0x5f, 0x6b, 0x65, 0x79, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, + 0x70, 0x63, 0x2e, 0x43, 0x69, 0x72, 0x63, 0x75, 0x69, 0x74, 0x4b, 0x65, 0x79, 0x52, 0x12, 0x69, + 0x6e, 0x63, 0x6f, 0x6d, 0x69, 0x6e, 0x67, 0x43, 0x69, 0x72, 0x63, 0x75, 0x69, 0x74, 0x4b, 0x65, + 0x79, 0x12, 0x3b, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x0e, 0x32, 0x23, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x52, 0x65, + 0x73, 0x6f, 0x6c, 0x76, 0x65, 0x48, 0x6f, 0x6c, 0x64, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, + 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1a, + 0x0a, 0x08, 0x70, 0x72, 0x65, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, + 0x52, 0x08, 0x70, 0x72, 0x65, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x27, 0x0a, 0x0f, 0x66, 0x61, + 0x69, 0x6c, 0x75, 0x72, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x04, 0x20, + 0x01, 0x28, 0x0c, 0x52, 0x0e, 0x66, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x4d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x12, 0x3d, 0x0a, 0x0c, 0x66, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x5f, 0x63, + 0x6f, 0x64, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x6c, 0x6e, 0x72, 0x70, + 0x63, 0x2e, 0x46, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x2e, 0x46, 0x61, 0x69, 0x6c, 0x75, 0x72, + 0x65, 0x43, 0x6f, 0x64, 0x65, 0x52, 0x0b, 0x66, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x43, 0x6f, + 0x64, 0x65, 0x12, 0x24, 0x0a, 0x0e, 0x69, 0x6e, 0x5f, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x5f, + 0x6d, 0x73, 0x61, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0c, 0x69, 0x6e, 0x41, 0x6d, + 0x6f, 0x75, 0x6e, 0x74, 0x4d, 0x73, 0x61, 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x6f, 0x75, 0x74, 0x5f, + 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x6d, 0x73, 0x61, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, + 0x04, 0x52, 0x0d, 0x6f, 0x75, 0x74, 0x41, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x4d, 0x73, 0x61, 0x74, + 0x12, 0x78, 0x0a, 0x17, 0x6f, 0x75, 0x74, 0x5f, 0x77, 0x69, 0x72, 0x65, 0x5f, 0x63, 0x75, 0x73, + 0x74, 0x6f, 0x6d, 0x5f, 0x72, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x41, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x46, 0x6f, + 0x72, 0x77, 0x61, 0x72, 0x64, 0x48, 0x74, 0x6c, 0x63, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, + 0x70, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x4f, 0x75, 0x74, 0x57, 0x69, + 0x72, 0x65, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x45, + 0x6e, 0x74, 0x72, 0x79, 0x52, 0x14, 0x6f, 0x75, 0x74, 0x57, 0x69, 0x72, 0x65, 0x43, 0x75, 0x73, + 0x74, 0x6f, 0x6d, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x1a, 0x47, 0x0a, 0x19, 0x4f, 0x75, 0x74, 0x57, 0x69, 0x72, 0x65, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x52, 0x65, 0x63, 0x6f, 0x72, - 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x14, 0x6f, 0x75, 0x74, 0x57, 0x69, 0x72, 0x65, - 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x1a, 0x47, 0x0a, - 0x19, 0x4f, 0x75, 0x74, 0x57, 0x69, 0x72, 0x65, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x52, 0x65, - 0x63, 0x6f, 0x72, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, - 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, - 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, 0x61, 0x6c, - 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x82, 0x01, 0x0a, 0x17, 0x55, 0x70, 0x64, 0x61, 0x74, - 0x65, 0x43, 0x68, 0x61, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x12, 0x32, 0x0a, 0x0a, 0x63, 0x68, 0x61, 0x6e, 0x5f, 0x70, 0x6f, 0x69, 0x6e, 0x74, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x6c, 0x6e, 0x72, 0x70, 0x63, 0x2e, 0x43, - 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x6f, 0x69, 0x6e, 0x74, 0x52, 0x09, 0x63, 0x68, 0x61, - 0x6e, 0x50, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x33, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1b, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, - 0x70, 0x63, 0x2e, 0x43, 0x68, 0x61, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x41, 0x63, 0x74, - 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x1a, 0x0a, 0x18, 0x55, - 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x68, 0x61, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x43, 0x0a, 0x11, 0x41, 0x64, 0x64, 0x41, 0x6c, - 0x69, 0x61, 0x73, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x0a, - 0x61, 0x6c, 0x69, 0x61, 0x73, 0x5f, 0x6d, 0x61, 0x70, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, - 0x32, 0x0f, 0x2e, 0x6c, 0x6e, 0x72, 0x70, 0x63, 0x2e, 0x41, 0x6c, 0x69, 0x61, 0x73, 0x4d, 0x61, - 0x70, 0x52, 0x09, 0x61, 0x6c, 0x69, 0x61, 0x73, 0x4d, 0x61, 0x70, 0x73, 0x22, 0x44, 0x0a, 0x12, - 0x41, 0x64, 0x64, 0x41, 0x6c, 0x69, 0x61, 0x73, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x2e, 0x0a, 0x0a, 0x61, 0x6c, 0x69, 0x61, 0x73, 0x5f, 0x6d, 0x61, 0x70, 0x73, - 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x6c, 0x6e, 0x72, 0x70, 0x63, 0x2e, 0x41, - 0x6c, 0x69, 0x61, 0x73, 0x4d, 0x61, 0x70, 0x52, 0x09, 0x61, 0x6c, 0x69, 0x61, 0x73, 0x4d, 0x61, - 0x70, 0x73, 0x22, 0x46, 0x0a, 0x14, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x41, 0x6c, 0x69, 0x61, - 0x73, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x0a, 0x61, 0x6c, - 0x69, 0x61, 0x73, 0x5f, 0x6d, 0x61, 0x70, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0f, - 0x2e, 0x6c, 0x6e, 0x72, 0x70, 0x63, 0x2e, 0x41, 0x6c, 0x69, 0x61, 0x73, 0x4d, 0x61, 0x70, 0x52, - 0x09, 0x61, 0x6c, 0x69, 0x61, 0x73, 0x4d, 0x61, 0x70, 0x73, 0x22, 0x47, 0x0a, 0x15, 0x44, 0x65, - 0x6c, 0x65, 0x74, 0x65, 0x41, 0x6c, 0x69, 0x61, 0x73, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x2e, 0x0a, 0x0a, 0x61, 0x6c, 0x69, 0x61, 0x73, 0x5f, 0x6d, 0x61, 0x70, - 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x6c, 0x6e, 0x72, 0x70, 0x63, 0x2e, - 0x41, 0x6c, 0x69, 0x61, 0x73, 0x4d, 0x61, 0x70, 0x52, 0x09, 0x61, 0x6c, 0x69, 0x61, 0x73, 0x4d, - 0x61, 0x70, 0x73, 0x22, 0x2c, 0x0a, 0x14, 0x46, 0x69, 0x6e, 0x64, 0x42, 0x61, 0x73, 0x65, 0x41, - 0x6c, 0x69, 0x61, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x61, - 0x6c, 0x69, 0x61, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x61, 0x6c, 0x69, 0x61, - 0x73, 0x22, 0x2b, 0x0a, 0x15, 0x46, 0x69, 0x6e, 0x64, 0x42, 0x61, 0x73, 0x65, 0x41, 0x6c, 0x69, - 0x61, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x62, 0x61, - 0x73, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x04, 0x62, 0x61, 0x73, 0x65, 0x2a, 0x81, - 0x04, 0x0a, 0x0d, 0x46, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, - 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x0d, 0x0a, - 0x09, 0x4e, 0x4f, 0x5f, 0x44, 0x45, 0x54, 0x41, 0x49, 0x4c, 0x10, 0x01, 0x12, 0x10, 0x0a, 0x0c, - 0x4f, 0x4e, 0x49, 0x4f, 0x4e, 0x5f, 0x44, 0x45, 0x43, 0x4f, 0x44, 0x45, 0x10, 0x02, 0x12, 0x15, - 0x0a, 0x11, 0x4c, 0x49, 0x4e, 0x4b, 0x5f, 0x4e, 0x4f, 0x54, 0x5f, 0x45, 0x4c, 0x49, 0x47, 0x49, - 0x42, 0x4c, 0x45, 0x10, 0x03, 0x12, 0x14, 0x0a, 0x10, 0x4f, 0x4e, 0x5f, 0x43, 0x48, 0x41, 0x49, - 0x4e, 0x5f, 0x54, 0x49, 0x4d, 0x45, 0x4f, 0x55, 0x54, 0x10, 0x04, 0x12, 0x14, 0x0a, 0x10, 0x48, - 0x54, 0x4c, 0x43, 0x5f, 0x45, 0x58, 0x43, 0x45, 0x45, 0x44, 0x53, 0x5f, 0x4d, 0x41, 0x58, 0x10, - 0x05, 0x12, 0x18, 0x0a, 0x14, 0x49, 0x4e, 0x53, 0x55, 0x46, 0x46, 0x49, 0x43, 0x49, 0x45, 0x4e, - 0x54, 0x5f, 0x42, 0x41, 0x4c, 0x41, 0x4e, 0x43, 0x45, 0x10, 0x06, 0x12, 0x16, 0x0a, 0x12, 0x49, - 0x4e, 0x43, 0x4f, 0x4d, 0x50, 0x4c, 0x45, 0x54, 0x45, 0x5f, 0x46, 0x4f, 0x52, 0x57, 0x41, 0x52, - 0x44, 0x10, 0x07, 0x12, 0x13, 0x0a, 0x0f, 0x48, 0x54, 0x4c, 0x43, 0x5f, 0x41, 0x44, 0x44, 0x5f, - 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x08, 0x12, 0x15, 0x0a, 0x11, 0x46, 0x4f, 0x52, 0x57, - 0x41, 0x52, 0x44, 0x53, 0x5f, 0x44, 0x49, 0x53, 0x41, 0x42, 0x4c, 0x45, 0x44, 0x10, 0x09, 0x12, - 0x14, 0x0a, 0x10, 0x49, 0x4e, 0x56, 0x4f, 0x49, 0x43, 0x45, 0x5f, 0x43, 0x41, 0x4e, 0x43, 0x45, - 0x4c, 0x45, 0x44, 0x10, 0x0a, 0x12, 0x15, 0x0a, 0x11, 0x49, 0x4e, 0x56, 0x4f, 0x49, 0x43, 0x45, - 0x5f, 0x55, 0x4e, 0x44, 0x45, 0x52, 0x50, 0x41, 0x49, 0x44, 0x10, 0x0b, 0x12, 0x1b, 0x0a, 0x17, - 0x49, 0x4e, 0x56, 0x4f, 0x49, 0x43, 0x45, 0x5f, 0x45, 0x58, 0x50, 0x49, 0x52, 0x59, 0x5f, 0x54, - 0x4f, 0x4f, 0x5f, 0x53, 0x4f, 0x4f, 0x4e, 0x10, 0x0c, 0x12, 0x14, 0x0a, 0x10, 0x49, 0x4e, 0x56, - 0x4f, 0x49, 0x43, 0x45, 0x5f, 0x4e, 0x4f, 0x54, 0x5f, 0x4f, 0x50, 0x45, 0x4e, 0x10, 0x0d, 0x12, - 0x17, 0x0a, 0x13, 0x4d, 0x50, 0x50, 0x5f, 0x49, 0x4e, 0x56, 0x4f, 0x49, 0x43, 0x45, 0x5f, 0x54, - 0x49, 0x4d, 0x45, 0x4f, 0x55, 0x54, 0x10, 0x0e, 0x12, 0x14, 0x0a, 0x10, 0x41, 0x44, 0x44, 0x52, - 0x45, 0x53, 0x53, 0x5f, 0x4d, 0x49, 0x53, 0x4d, 0x41, 0x54, 0x43, 0x48, 0x10, 0x0f, 0x12, 0x16, - 0x0a, 0x12, 0x53, 0x45, 0x54, 0x5f, 0x54, 0x4f, 0x54, 0x41, 0x4c, 0x5f, 0x4d, 0x49, 0x53, 0x4d, - 0x41, 0x54, 0x43, 0x48, 0x10, 0x10, 0x12, 0x15, 0x0a, 0x11, 0x53, 0x45, 0x54, 0x5f, 0x54, 0x4f, - 0x54, 0x41, 0x4c, 0x5f, 0x54, 0x4f, 0x4f, 0x5f, 0x4c, 0x4f, 0x57, 0x10, 0x11, 0x12, 0x10, 0x0a, - 0x0c, 0x53, 0x45, 0x54, 0x5f, 0x4f, 0x56, 0x45, 0x52, 0x50, 0x41, 0x49, 0x44, 0x10, 0x12, 0x12, - 0x13, 0x0a, 0x0f, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x5f, 0x49, 0x4e, 0x56, 0x4f, 0x49, - 0x43, 0x45, 0x10, 0x13, 0x12, 0x13, 0x0a, 0x0f, 0x49, 0x4e, 0x56, 0x41, 0x4c, 0x49, 0x44, 0x5f, - 0x4b, 0x45, 0x59, 0x53, 0x45, 0x4e, 0x44, 0x10, 0x14, 0x12, 0x13, 0x0a, 0x0f, 0x4d, 0x50, 0x50, - 0x5f, 0x49, 0x4e, 0x5f, 0x50, 0x52, 0x4f, 0x47, 0x52, 0x45, 0x53, 0x53, 0x10, 0x15, 0x12, 0x12, - 0x0a, 0x0e, 0x43, 0x49, 0x52, 0x43, 0x55, 0x4c, 0x41, 0x52, 0x5f, 0x52, 0x4f, 0x55, 0x54, 0x45, - 0x10, 0x16, 0x2a, 0xae, 0x01, 0x0a, 0x0c, 0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x74, - 0x61, 0x74, 0x65, 0x12, 0x0d, 0x0a, 0x09, 0x49, 0x4e, 0x5f, 0x46, 0x4c, 0x49, 0x47, 0x48, 0x54, - 0x10, 0x00, 0x12, 0x0d, 0x0a, 0x09, 0x53, 0x55, 0x43, 0x43, 0x45, 0x45, 0x44, 0x45, 0x44, 0x10, - 0x01, 0x12, 0x12, 0x0a, 0x0e, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x5f, 0x54, 0x49, 0x4d, 0x45, - 0x4f, 0x55, 0x54, 0x10, 0x02, 0x12, 0x13, 0x0a, 0x0f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x5f, - 0x4e, 0x4f, 0x5f, 0x52, 0x4f, 0x55, 0x54, 0x45, 0x10, 0x03, 0x12, 0x10, 0x0a, 0x0c, 0x46, 0x41, - 0x49, 0x4c, 0x45, 0x44, 0x5f, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x04, 0x12, 0x24, 0x0a, 0x20, - 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x5f, 0x49, 0x4e, 0x43, 0x4f, 0x52, 0x52, 0x45, 0x43, 0x54, - 0x5f, 0x50, 0x41, 0x59, 0x4d, 0x45, 0x4e, 0x54, 0x5f, 0x44, 0x45, 0x54, 0x41, 0x49, 0x4c, 0x53, - 0x10, 0x05, 0x12, 0x1f, 0x0a, 0x1b, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x5f, 0x49, 0x4e, 0x53, - 0x55, 0x46, 0x46, 0x49, 0x43, 0x49, 0x45, 0x4e, 0x54, 0x5f, 0x42, 0x41, 0x4c, 0x41, 0x4e, 0x43, - 0x45, 0x10, 0x06, 0x2a, 0x51, 0x0a, 0x18, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x76, 0x65, 0x48, 0x6f, - 0x6c, 0x64, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, - 0x0a, 0x0a, 0x06, 0x53, 0x45, 0x54, 0x54, 0x4c, 0x45, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x46, - 0x41, 0x49, 0x4c, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x52, 0x45, 0x53, 0x55, 0x4d, 0x45, 0x10, - 0x02, 0x12, 0x13, 0x0a, 0x0f, 0x52, 0x45, 0x53, 0x55, 0x4d, 0x45, 0x5f, 0x4d, 0x4f, 0x44, 0x49, - 0x46, 0x49, 0x45, 0x44, 0x10, 0x03, 0x2a, 0x35, 0x0a, 0x10, 0x43, 0x68, 0x61, 0x6e, 0x53, 0x74, - 0x61, 0x74, 0x75, 0x73, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x0a, 0x0a, 0x06, 0x45, 0x4e, - 0x41, 0x42, 0x4c, 0x45, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x49, 0x53, 0x41, 0x42, 0x4c, - 0x45, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x41, 0x55, 0x54, 0x4f, 0x10, 0x02, 0x32, 0xc6, 0x0e, - 0x0a, 0x06, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x12, 0x40, 0x0a, 0x0d, 0x53, 0x65, 0x6e, 0x64, - 0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x56, 0x32, 0x12, 0x1d, 0x2e, 0x72, 0x6f, 0x75, 0x74, - 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, - 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0e, 0x2e, 0x6c, 0x6e, 0x72, 0x70, 0x63, - 0x2e, 0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x30, 0x01, 0x12, 0x42, 0x0a, 0x0e, 0x54, 0x72, - 0x61, 0x63, 0x6b, 0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x56, 0x32, 0x12, 0x1e, 0x2e, 0x72, + 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x04, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, + 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, + 0x02, 0x38, 0x01, 0x22, 0x82, 0x01, 0x0a, 0x17, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x68, + 0x61, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x32, 0x0a, 0x0a, 0x63, 0x68, 0x61, 0x6e, 0x5f, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x6c, 0x6e, 0x72, 0x70, 0x63, 0x2e, 0x43, 0x68, 0x61, 0x6e, + 0x6e, 0x65, 0x6c, 0x50, 0x6f, 0x69, 0x6e, 0x74, 0x52, 0x09, 0x63, 0x68, 0x61, 0x6e, 0x50, 0x6f, + 0x69, 0x6e, 0x74, 0x12, 0x33, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0e, 0x32, 0x1b, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, + 0x43, 0x68, 0x61, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, + 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x1a, 0x0a, 0x18, 0x55, 0x70, 0x64, 0x61, + 0x74, 0x65, 0x43, 0x68, 0x61, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x43, 0x0a, 0x11, 0x41, 0x64, 0x64, 0x41, 0x6c, 0x69, 0x61, 0x73, + 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x0a, 0x61, 0x6c, 0x69, + 0x61, 0x73, 0x5f, 0x6d, 0x61, 0x70, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0f, 0x2e, + 0x6c, 0x6e, 0x72, 0x70, 0x63, 0x2e, 0x41, 0x6c, 0x69, 0x61, 0x73, 0x4d, 0x61, 0x70, 0x52, 0x09, + 0x61, 0x6c, 0x69, 0x61, 0x73, 0x4d, 0x61, 0x70, 0x73, 0x22, 0x44, 0x0a, 0x12, 0x41, 0x64, 0x64, + 0x41, 0x6c, 0x69, 0x61, 0x73, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x2e, 0x0a, 0x0a, 0x61, 0x6c, 0x69, 0x61, 0x73, 0x5f, 0x6d, 0x61, 0x70, 0x73, 0x18, 0x01, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x6c, 0x6e, 0x72, 0x70, 0x63, 0x2e, 0x41, 0x6c, 0x69, 0x61, + 0x73, 0x4d, 0x61, 0x70, 0x52, 0x09, 0x61, 0x6c, 0x69, 0x61, 0x73, 0x4d, 0x61, 0x70, 0x73, 0x22, + 0x46, 0x0a, 0x14, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x41, 0x6c, 0x69, 0x61, 0x73, 0x65, 0x73, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x0a, 0x61, 0x6c, 0x69, 0x61, 0x73, + 0x5f, 0x6d, 0x61, 0x70, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x6c, 0x6e, + 0x72, 0x70, 0x63, 0x2e, 0x41, 0x6c, 0x69, 0x61, 0x73, 0x4d, 0x61, 0x70, 0x52, 0x09, 0x61, 0x6c, + 0x69, 0x61, 0x73, 0x4d, 0x61, 0x70, 0x73, 0x22, 0x47, 0x0a, 0x15, 0x44, 0x65, 0x6c, 0x65, 0x74, + 0x65, 0x41, 0x6c, 0x69, 0x61, 0x73, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x2e, 0x0a, 0x0a, 0x61, 0x6c, 0x69, 0x61, 0x73, 0x5f, 0x6d, 0x61, 0x70, 0x73, 0x18, 0x01, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x6c, 0x6e, 0x72, 0x70, 0x63, 0x2e, 0x41, 0x6c, 0x69, + 0x61, 0x73, 0x4d, 0x61, 0x70, 0x52, 0x09, 0x61, 0x6c, 0x69, 0x61, 0x73, 0x4d, 0x61, 0x70, 0x73, + 0x22, 0x2c, 0x0a, 0x14, 0x46, 0x69, 0x6e, 0x64, 0x42, 0x61, 0x73, 0x65, 0x41, 0x6c, 0x69, 0x61, + 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x61, 0x6c, 0x69, 0x61, + 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x61, 0x6c, 0x69, 0x61, 0x73, 0x22, 0x2b, + 0x0a, 0x15, 0x46, 0x69, 0x6e, 0x64, 0x42, 0x61, 0x73, 0x65, 0x41, 0x6c, 0x69, 0x61, 0x73, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x62, 0x61, 0x73, 0x65, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x04, 0x62, 0x61, 0x73, 0x65, 0x2a, 0x81, 0x04, 0x0a, 0x0d, + 0x46, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x12, 0x0b, 0x0a, + 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x0d, 0x0a, 0x09, 0x4e, 0x4f, + 0x5f, 0x44, 0x45, 0x54, 0x41, 0x49, 0x4c, 0x10, 0x01, 0x12, 0x10, 0x0a, 0x0c, 0x4f, 0x4e, 0x49, + 0x4f, 0x4e, 0x5f, 0x44, 0x45, 0x43, 0x4f, 0x44, 0x45, 0x10, 0x02, 0x12, 0x15, 0x0a, 0x11, 0x4c, + 0x49, 0x4e, 0x4b, 0x5f, 0x4e, 0x4f, 0x54, 0x5f, 0x45, 0x4c, 0x49, 0x47, 0x49, 0x42, 0x4c, 0x45, + 0x10, 0x03, 0x12, 0x14, 0x0a, 0x10, 0x4f, 0x4e, 0x5f, 0x43, 0x48, 0x41, 0x49, 0x4e, 0x5f, 0x54, + 0x49, 0x4d, 0x45, 0x4f, 0x55, 0x54, 0x10, 0x04, 0x12, 0x14, 0x0a, 0x10, 0x48, 0x54, 0x4c, 0x43, + 0x5f, 0x45, 0x58, 0x43, 0x45, 0x45, 0x44, 0x53, 0x5f, 0x4d, 0x41, 0x58, 0x10, 0x05, 0x12, 0x18, + 0x0a, 0x14, 0x49, 0x4e, 0x53, 0x55, 0x46, 0x46, 0x49, 0x43, 0x49, 0x45, 0x4e, 0x54, 0x5f, 0x42, + 0x41, 0x4c, 0x41, 0x4e, 0x43, 0x45, 0x10, 0x06, 0x12, 0x16, 0x0a, 0x12, 0x49, 0x4e, 0x43, 0x4f, + 0x4d, 0x50, 0x4c, 0x45, 0x54, 0x45, 0x5f, 0x46, 0x4f, 0x52, 0x57, 0x41, 0x52, 0x44, 0x10, 0x07, + 0x12, 0x13, 0x0a, 0x0f, 0x48, 0x54, 0x4c, 0x43, 0x5f, 0x41, 0x44, 0x44, 0x5f, 0x46, 0x41, 0x49, + 0x4c, 0x45, 0x44, 0x10, 0x08, 0x12, 0x15, 0x0a, 0x11, 0x46, 0x4f, 0x52, 0x57, 0x41, 0x52, 0x44, + 0x53, 0x5f, 0x44, 0x49, 0x53, 0x41, 0x42, 0x4c, 0x45, 0x44, 0x10, 0x09, 0x12, 0x14, 0x0a, 0x10, + 0x49, 0x4e, 0x56, 0x4f, 0x49, 0x43, 0x45, 0x5f, 0x43, 0x41, 0x4e, 0x43, 0x45, 0x4c, 0x45, 0x44, + 0x10, 0x0a, 0x12, 0x15, 0x0a, 0x11, 0x49, 0x4e, 0x56, 0x4f, 0x49, 0x43, 0x45, 0x5f, 0x55, 0x4e, + 0x44, 0x45, 0x52, 0x50, 0x41, 0x49, 0x44, 0x10, 0x0b, 0x12, 0x1b, 0x0a, 0x17, 0x49, 0x4e, 0x56, + 0x4f, 0x49, 0x43, 0x45, 0x5f, 0x45, 0x58, 0x50, 0x49, 0x52, 0x59, 0x5f, 0x54, 0x4f, 0x4f, 0x5f, + 0x53, 0x4f, 0x4f, 0x4e, 0x10, 0x0c, 0x12, 0x14, 0x0a, 0x10, 0x49, 0x4e, 0x56, 0x4f, 0x49, 0x43, + 0x45, 0x5f, 0x4e, 0x4f, 0x54, 0x5f, 0x4f, 0x50, 0x45, 0x4e, 0x10, 0x0d, 0x12, 0x17, 0x0a, 0x13, + 0x4d, 0x50, 0x50, 0x5f, 0x49, 0x4e, 0x56, 0x4f, 0x49, 0x43, 0x45, 0x5f, 0x54, 0x49, 0x4d, 0x45, + 0x4f, 0x55, 0x54, 0x10, 0x0e, 0x12, 0x14, 0x0a, 0x10, 0x41, 0x44, 0x44, 0x52, 0x45, 0x53, 0x53, + 0x5f, 0x4d, 0x49, 0x53, 0x4d, 0x41, 0x54, 0x43, 0x48, 0x10, 0x0f, 0x12, 0x16, 0x0a, 0x12, 0x53, + 0x45, 0x54, 0x5f, 0x54, 0x4f, 0x54, 0x41, 0x4c, 0x5f, 0x4d, 0x49, 0x53, 0x4d, 0x41, 0x54, 0x43, + 0x48, 0x10, 0x10, 0x12, 0x15, 0x0a, 0x11, 0x53, 0x45, 0x54, 0x5f, 0x54, 0x4f, 0x54, 0x41, 0x4c, + 0x5f, 0x54, 0x4f, 0x4f, 0x5f, 0x4c, 0x4f, 0x57, 0x10, 0x11, 0x12, 0x10, 0x0a, 0x0c, 0x53, 0x45, + 0x54, 0x5f, 0x4f, 0x56, 0x45, 0x52, 0x50, 0x41, 0x49, 0x44, 0x10, 0x12, 0x12, 0x13, 0x0a, 0x0f, + 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x5f, 0x49, 0x4e, 0x56, 0x4f, 0x49, 0x43, 0x45, 0x10, + 0x13, 0x12, 0x13, 0x0a, 0x0f, 0x49, 0x4e, 0x56, 0x41, 0x4c, 0x49, 0x44, 0x5f, 0x4b, 0x45, 0x59, + 0x53, 0x45, 0x4e, 0x44, 0x10, 0x14, 0x12, 0x13, 0x0a, 0x0f, 0x4d, 0x50, 0x50, 0x5f, 0x49, 0x4e, + 0x5f, 0x50, 0x52, 0x4f, 0x47, 0x52, 0x45, 0x53, 0x53, 0x10, 0x15, 0x12, 0x12, 0x0a, 0x0e, 0x43, + 0x49, 0x52, 0x43, 0x55, 0x4c, 0x41, 0x52, 0x5f, 0x52, 0x4f, 0x55, 0x54, 0x45, 0x10, 0x16, 0x2a, + 0xae, 0x01, 0x0a, 0x0c, 0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65, + 0x12, 0x0d, 0x0a, 0x09, 0x49, 0x4e, 0x5f, 0x46, 0x4c, 0x49, 0x47, 0x48, 0x54, 0x10, 0x00, 0x12, + 0x0d, 0x0a, 0x09, 0x53, 0x55, 0x43, 0x43, 0x45, 0x45, 0x44, 0x45, 0x44, 0x10, 0x01, 0x12, 0x12, + 0x0a, 0x0e, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x5f, 0x54, 0x49, 0x4d, 0x45, 0x4f, 0x55, 0x54, + 0x10, 0x02, 0x12, 0x13, 0x0a, 0x0f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x5f, 0x4e, 0x4f, 0x5f, + 0x52, 0x4f, 0x55, 0x54, 0x45, 0x10, 0x03, 0x12, 0x10, 0x0a, 0x0c, 0x46, 0x41, 0x49, 0x4c, 0x45, + 0x44, 0x5f, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x04, 0x12, 0x24, 0x0a, 0x20, 0x46, 0x41, 0x49, + 0x4c, 0x45, 0x44, 0x5f, 0x49, 0x4e, 0x43, 0x4f, 0x52, 0x52, 0x45, 0x43, 0x54, 0x5f, 0x50, 0x41, + 0x59, 0x4d, 0x45, 0x4e, 0x54, 0x5f, 0x44, 0x45, 0x54, 0x41, 0x49, 0x4c, 0x53, 0x10, 0x05, 0x12, + 0x1f, 0x0a, 0x1b, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x5f, 0x49, 0x4e, 0x53, 0x55, 0x46, 0x46, + 0x49, 0x43, 0x49, 0x45, 0x4e, 0x54, 0x5f, 0x42, 0x41, 0x4c, 0x41, 0x4e, 0x43, 0x45, 0x10, 0x06, + 0x2a, 0x51, 0x0a, 0x18, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x76, 0x65, 0x48, 0x6f, 0x6c, 0x64, 0x46, + 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x0a, 0x0a, 0x06, + 0x53, 0x45, 0x54, 0x54, 0x4c, 0x45, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x46, 0x41, 0x49, 0x4c, + 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x52, 0x45, 0x53, 0x55, 0x4d, 0x45, 0x10, 0x02, 0x12, 0x13, + 0x0a, 0x0f, 0x52, 0x45, 0x53, 0x55, 0x4d, 0x45, 0x5f, 0x4d, 0x4f, 0x44, 0x49, 0x46, 0x49, 0x45, + 0x44, 0x10, 0x03, 0x2a, 0x35, 0x0a, 0x10, 0x43, 0x68, 0x61, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, + 0x73, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x0a, 0x0a, 0x06, 0x45, 0x4e, 0x41, 0x42, 0x4c, + 0x45, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x49, 0x53, 0x41, 0x42, 0x4c, 0x45, 0x10, 0x01, + 0x12, 0x08, 0x0a, 0x04, 0x41, 0x55, 0x54, 0x4f, 0x10, 0x02, 0x32, 0xc6, 0x0e, 0x0a, 0x06, 0x52, + 0x6f, 0x75, 0x74, 0x65, 0x72, 0x12, 0x40, 0x0a, 0x0d, 0x53, 0x65, 0x6e, 0x64, 0x50, 0x61, 0x79, + 0x6d, 0x65, 0x6e, 0x74, 0x56, 0x32, 0x12, 0x1d, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, + 0x70, 0x63, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0e, 0x2e, 0x6c, 0x6e, 0x72, 0x70, 0x63, 0x2e, 0x50, 0x61, + 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x30, 0x01, 0x12, 0x42, 0x0a, 0x0e, 0x54, 0x72, 0x61, 0x63, 0x6b, + 0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x56, 0x32, 0x12, 0x1e, 0x2e, 0x72, 0x6f, 0x75, 0x74, + 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x54, 0x72, 0x61, 0x63, 0x6b, 0x50, 0x61, 0x79, 0x6d, 0x65, + 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0e, 0x2e, 0x6c, 0x6e, 0x72, 0x70, + 0x63, 0x2e, 0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x30, 0x01, 0x12, 0x42, 0x0a, 0x0d, 0x54, + 0x72, 0x61, 0x63, 0x6b, 0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x1f, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x54, 0x72, 0x61, 0x63, 0x6b, 0x50, 0x61, - 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0e, 0x2e, 0x6c, - 0x6e, 0x72, 0x70, 0x63, 0x2e, 0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x30, 0x01, 0x12, 0x42, - 0x0a, 0x0d, 0x54, 0x72, 0x61, 0x63, 0x6b, 0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x12, - 0x1f, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x54, 0x72, 0x61, 0x63, - 0x6b, 0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x0e, 0x2e, 0x6c, 0x6e, 0x72, 0x70, 0x63, 0x2e, 0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, - 0x30, 0x01, 0x12, 0x4b, 0x0a, 0x10, 0x45, 0x73, 0x74, 0x69, 0x6d, 0x61, 0x74, 0x65, 0x52, 0x6f, - 0x75, 0x74, 0x65, 0x46, 0x65, 0x65, 0x12, 0x1a, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, - 0x70, 0x63, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x65, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x52, - 0x6f, 0x75, 0x74, 0x65, 0x46, 0x65, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, - 0x51, 0x0a, 0x0b, 0x53, 0x65, 0x6e, 0x64, 0x54, 0x6f, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x1d, - 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x54, - 0x6f, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, - 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x54, 0x6f, - 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x03, 0x88, - 0x02, 0x01, 0x12, 0x42, 0x0a, 0x0d, 0x53, 0x65, 0x6e, 0x64, 0x54, 0x6f, 0x52, 0x6f, 0x75, 0x74, - 0x65, 0x56, 0x32, 0x12, 0x1d, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, - 0x53, 0x65, 0x6e, 0x64, 0x54, 0x6f, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x12, 0x2e, 0x6c, 0x6e, 0x72, 0x70, 0x63, 0x2e, 0x48, 0x54, 0x4c, 0x43, 0x41, - 0x74, 0x74, 0x65, 0x6d, 0x70, 0x74, 0x12, 0x64, 0x0a, 0x13, 0x52, 0x65, 0x73, 0x65, 0x74, 0x4d, - 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x12, 0x25, 0x2e, - 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x52, 0x65, 0x73, 0x65, 0x74, 0x4d, - 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, - 0x2e, 0x52, 0x65, 0x73, 0x65, 0x74, 0x4d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, - 0x74, 0x72, 0x6f, 0x6c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x64, 0x0a, 0x13, - 0x51, 0x75, 0x65, 0x72, 0x79, 0x4d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x74, - 0x72, 0x6f, 0x6c, 0x12, 0x25, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, - 0x51, 0x75, 0x65, 0x72, 0x79, 0x4d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x74, - 0x72, 0x6f, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x72, 0x6f, 0x75, - 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x4d, 0x69, 0x73, 0x73, - 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x6a, 0x0a, 0x15, 0x58, 0x49, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x4d, 0x69, 0x73, - 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x12, 0x27, 0x2e, 0x72, 0x6f, - 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x58, 0x49, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x4d, - 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, - 0x2e, 0x58, 0x49, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x4d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x43, - 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x70, - 0x0a, 0x17, 0x47, 0x65, 0x74, 0x4d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x74, - 0x72, 0x6f, 0x6c, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x29, 0x2e, 0x72, 0x6f, 0x75, 0x74, - 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, - 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, - 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x74, 0x72, - 0x6f, 0x6c, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x70, 0x0a, 0x17, 0x53, 0x65, 0x74, 0x4d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x6f, - 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x29, 0x2e, 0x72, 0x6f, - 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x65, 0x74, 0x4d, 0x69, 0x73, 0x73, 0x69, - 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, - 0x70, 0x63, 0x2e, 0x53, 0x65, 0x74, 0x4d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, - 0x74, 0x72, 0x6f, 0x6c, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x5b, 0x0a, 0x10, 0x51, 0x75, 0x65, 0x72, 0x79, 0x50, 0x72, 0x6f, 0x62, 0x61, - 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x12, 0x22, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, - 0x70, 0x63, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x50, 0x72, 0x6f, 0x62, 0x61, 0x62, 0x69, 0x6c, - 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x72, 0x6f, 0x75, - 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x50, 0x72, 0x6f, 0x62, - 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, - 0x49, 0x0a, 0x0a, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x1c, 0x2e, - 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x52, - 0x6f, 0x75, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x72, 0x6f, - 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x52, 0x6f, 0x75, - 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x54, 0x0a, 0x13, 0x53, 0x75, - 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x48, 0x74, 0x6c, 0x63, 0x45, 0x76, 0x65, 0x6e, 0x74, - 0x73, 0x12, 0x25, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x75, - 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x48, 0x74, 0x6c, 0x63, 0x45, 0x76, 0x65, 0x6e, 0x74, - 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x14, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, - 0x72, 0x72, 0x70, 0x63, 0x2e, 0x48, 0x74, 0x6c, 0x63, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x30, 0x01, - 0x12, 0x4d, 0x0a, 0x0b, 0x53, 0x65, 0x6e, 0x64, 0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x12, - 0x1d, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x65, 0x6e, 0x64, - 0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, - 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x50, 0x61, 0x79, 0x6d, 0x65, - 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x03, 0x88, 0x02, 0x01, 0x30, 0x01, 0x12, - 0x4f, 0x0a, 0x0c, 0x54, 0x72, 0x61, 0x63, 0x6b, 0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x12, - 0x1e, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x54, 0x72, 0x61, 0x63, - 0x6b, 0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, - 0x18, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x50, 0x61, 0x79, 0x6d, - 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x03, 0x88, 0x02, 0x01, 0x30, 0x01, - 0x12, 0x66, 0x0a, 0x0f, 0x48, 0x74, 0x6c, 0x63, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, - 0x74, 0x6f, 0x72, 0x12, 0x27, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, - 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x48, 0x74, 0x6c, 0x63, 0x49, 0x6e, 0x74, 0x65, 0x72, - 0x63, 0x65, 0x70, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x1a, 0x26, 0x2e, 0x72, - 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, - 0x48, 0x74, 0x6c, 0x63, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x28, 0x01, 0x30, 0x01, 0x12, 0x5b, 0x0a, 0x10, 0x55, 0x70, 0x64, 0x61, - 0x74, 0x65, 0x43, 0x68, 0x61, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x22, 0x2e, 0x72, - 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, - 0x68, 0x61, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x23, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x55, 0x70, 0x64, - 0x61, 0x74, 0x65, 0x43, 0x68, 0x61, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a, 0x14, 0x58, 0x41, 0x64, 0x64, 0x4c, 0x6f, 0x63, - 0x61, 0x6c, 0x43, 0x68, 0x61, 0x6e, 0x41, 0x6c, 0x69, 0x61, 0x73, 0x65, 0x73, 0x12, 0x1c, 0x2e, - 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x41, 0x64, 0x64, 0x41, 0x6c, 0x69, - 0x61, 0x73, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x72, 0x6f, - 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x41, 0x64, 0x64, 0x41, 0x6c, 0x69, 0x61, 0x73, - 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5c, 0x0a, 0x17, 0x58, 0x44, - 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x43, 0x68, 0x61, 0x6e, 0x41, 0x6c, - 0x69, 0x61, 0x73, 0x65, 0x73, 0x12, 0x1f, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, - 0x63, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x41, 0x6c, 0x69, 0x61, 0x73, 0x65, 0x73, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, - 0x70, 0x63, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x41, 0x6c, 0x69, 0x61, 0x73, 0x65, 0x73, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5c, 0x0a, 0x17, 0x58, 0x46, 0x69, 0x6e, - 0x64, 0x42, 0x61, 0x73, 0x65, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x43, 0x68, 0x61, 0x6e, 0x41, 0x6c, - 0x69, 0x61, 0x73, 0x12, 0x1f, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, - 0x46, 0x69, 0x6e, 0x64, 0x42, 0x61, 0x73, 0x65, 0x41, 0x6c, 0x69, 0x61, 0x73, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, - 0x2e, 0x46, 0x69, 0x6e, 0x64, 0x42, 0x61, 0x73, 0x65, 0x41, 0x6c, 0x69, 0x61, 0x73, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x31, 0x5a, 0x2f, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, - 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6c, 0x69, 0x67, 0x68, 0x74, 0x6e, 0x69, 0x6e, 0x67, 0x6e, 0x65, - 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x2f, 0x6c, 0x6e, 0x64, 0x2f, 0x6c, 0x6e, 0x72, 0x70, 0x63, 0x2f, - 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x33, + 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0e, 0x2e, + 0x6c, 0x6e, 0x72, 0x70, 0x63, 0x2e, 0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x30, 0x01, 0x12, + 0x4b, 0x0a, 0x10, 0x45, 0x73, 0x74, 0x69, 0x6d, 0x61, 0x74, 0x65, 0x52, 0x6f, 0x75, 0x74, 0x65, + 0x46, 0x65, 0x65, 0x12, 0x1a, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, + 0x52, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x65, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x1b, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x52, 0x6f, 0x75, 0x74, + 0x65, 0x46, 0x65, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x51, 0x0a, 0x0b, + 0x53, 0x65, 0x6e, 0x64, 0x54, 0x6f, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x1d, 0x2e, 0x72, 0x6f, + 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x54, 0x6f, 0x52, 0x6f, + 0x75, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x72, 0x6f, 0x75, + 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x54, 0x6f, 0x52, 0x6f, 0x75, + 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x03, 0x88, 0x02, 0x01, 0x12, + 0x42, 0x0a, 0x0d, 0x53, 0x65, 0x6e, 0x64, 0x54, 0x6f, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x56, 0x32, + 0x12, 0x1d, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x65, 0x6e, + 0x64, 0x54, 0x6f, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x12, 0x2e, 0x6c, 0x6e, 0x72, 0x70, 0x63, 0x2e, 0x48, 0x54, 0x4c, 0x43, 0x41, 0x74, 0x74, 0x65, + 0x6d, 0x70, 0x74, 0x12, 0x64, 0x0a, 0x13, 0x52, 0x65, 0x73, 0x65, 0x74, 0x4d, 0x69, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x12, 0x25, 0x2e, 0x72, 0x6f, 0x75, + 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x52, 0x65, 0x73, 0x65, 0x74, 0x4d, 0x69, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x26, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x52, 0x65, + 0x73, 0x65, 0x74, 0x4d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, + 0x6c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x64, 0x0a, 0x13, 0x51, 0x75, 0x65, + 0x72, 0x79, 0x4d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, + 0x12, 0x25, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x51, 0x75, 0x65, + 0x72, 0x79, 0x4d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, + 0x72, 0x70, 0x63, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x4d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x6a, 0x0a, 0x15, 0x58, 0x49, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x4d, 0x69, 0x73, 0x73, 0x69, 0x6f, + 0x6e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x12, 0x27, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, + 0x72, 0x72, 0x70, 0x63, 0x2e, 0x58, 0x49, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x4d, 0x69, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x28, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x58, 0x49, + 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x4d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x74, + 0x72, 0x6f, 0x6c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x70, 0x0a, 0x17, 0x47, + 0x65, 0x74, 0x4d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, + 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x29, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, + 0x70, 0x63, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, + 0x74, 0x72, 0x6f, 0x6c, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x2a, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x47, 0x65, + 0x74, 0x4d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x43, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x70, 0x0a, + 0x17, 0x53, 0x65, 0x74, 0x4d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x74, 0x72, + 0x6f, 0x6c, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x29, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, + 0x72, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x65, 0x74, 0x4d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x43, + 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, + 0x53, 0x65, 0x74, 0x4d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, + 0x6c, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x5b, 0x0a, 0x10, 0x51, 0x75, 0x65, 0x72, 0x79, 0x50, 0x72, 0x6f, 0x62, 0x61, 0x62, 0x69, 0x6c, + 0x69, 0x74, 0x79, 0x12, 0x22, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, + 0x51, 0x75, 0x65, 0x72, 0x79, 0x50, 0x72, 0x6f, 0x62, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, + 0x72, 0x70, 0x63, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x50, 0x72, 0x6f, 0x62, 0x61, 0x62, 0x69, + 0x6c, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x49, 0x0a, 0x0a, + 0x42, 0x75, 0x69, 0x6c, 0x64, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x1c, 0x2e, 0x72, 0x6f, 0x75, + 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x52, 0x6f, 0x75, 0x74, + 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, + 0x72, 0x72, 0x70, 0x63, 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x54, 0x0a, 0x13, 0x53, 0x75, 0x62, 0x73, 0x63, + 0x72, 0x69, 0x62, 0x65, 0x48, 0x74, 0x6c, 0x63, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x25, + 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x75, 0x62, 0x73, 0x63, + 0x72, 0x69, 0x62, 0x65, 0x48, 0x74, 0x6c, 0x63, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x14, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, + 0x63, 0x2e, 0x48, 0x74, 0x6c, 0x63, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x30, 0x01, 0x12, 0x4d, 0x0a, + 0x0b, 0x53, 0x65, 0x6e, 0x64, 0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x1d, 0x2e, 0x72, + 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x50, 0x61, 0x79, + 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x72, 0x6f, + 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x53, + 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x03, 0x88, 0x02, 0x01, 0x30, 0x01, 0x12, 0x4f, 0x0a, 0x0c, + 0x54, 0x72, 0x61, 0x63, 0x6b, 0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x1e, 0x2e, 0x72, + 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x54, 0x72, 0x61, 0x63, 0x6b, 0x50, 0x61, + 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x72, + 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, + 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x03, 0x88, 0x02, 0x01, 0x30, 0x01, 0x12, 0x66, 0x0a, + 0x0f, 0x48, 0x74, 0x6c, 0x63, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x6f, 0x72, + 0x12, 0x27, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x46, 0x6f, 0x72, + 0x77, 0x61, 0x72, 0x64, 0x48, 0x74, 0x6c, 0x63, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, + 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x1a, 0x26, 0x2e, 0x72, 0x6f, 0x75, 0x74, + 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x48, 0x74, 0x6c, + 0x63, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x28, 0x01, 0x30, 0x01, 0x12, 0x5b, 0x0a, 0x10, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, + 0x68, 0x61, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x22, 0x2e, 0x72, 0x6f, 0x75, 0x74, + 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x68, 0x61, 0x6e, + 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, + 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, + 0x43, 0x68, 0x61, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x53, 0x0a, 0x14, 0x58, 0x41, 0x64, 0x64, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x43, + 0x68, 0x61, 0x6e, 0x41, 0x6c, 0x69, 0x61, 0x73, 0x65, 0x73, 0x12, 0x1c, 0x2e, 0x72, 0x6f, 0x75, + 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x41, 0x64, 0x64, 0x41, 0x6c, 0x69, 0x61, 0x73, 0x65, + 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, + 0x72, 0x72, 0x70, 0x63, 0x2e, 0x41, 0x64, 0x64, 0x41, 0x6c, 0x69, 0x61, 0x73, 0x65, 0x73, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5c, 0x0a, 0x17, 0x58, 0x44, 0x65, 0x6c, 0x65, + 0x74, 0x65, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x43, 0x68, 0x61, 0x6e, 0x41, 0x6c, 0x69, 0x61, 0x73, + 0x65, 0x73, 0x12, 0x1f, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x44, + 0x65, 0x6c, 0x65, 0x74, 0x65, 0x41, 0x6c, 0x69, 0x61, 0x73, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, + 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x41, 0x6c, 0x69, 0x61, 0x73, 0x65, 0x73, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5c, 0x0a, 0x17, 0x58, 0x46, 0x69, 0x6e, 0x64, 0x42, 0x61, + 0x73, 0x65, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x43, 0x68, 0x61, 0x6e, 0x41, 0x6c, 0x69, 0x61, 0x73, + 0x12, 0x1f, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x46, 0x69, 0x6e, + 0x64, 0x42, 0x61, 0x73, 0x65, 0x41, 0x6c, 0x69, 0x61, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x20, 0x2e, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x2e, 0x46, 0x69, + 0x6e, 0x64, 0x42, 0x61, 0x73, 0x65, 0x41, 0x6c, 0x69, 0x61, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x42, 0x31, 0x5a, 0x2f, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, + 0x6d, 0x2f, 0x6c, 0x69, 0x67, 0x68, 0x74, 0x6e, 0x69, 0x6e, 0x67, 0x6e, 0x65, 0x74, 0x77, 0x6f, + 0x72, 0x6b, 0x2f, 0x6c, 0x6e, 0x64, 0x2f, 0x6c, 0x6e, 0x72, 0x70, 0x63, 0x2f, 0x72, 0x6f, 0x75, + 0x74, 0x65, 0x72, 0x72, 0x70, 0x63, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/lnrpc/routerrpc/router.proto b/lnrpc/routerrpc/router.proto index 8f5502675..b6b3a906e 100644 --- a/lnrpc/routerrpc/router.proto +++ b/lnrpc/routerrpc/router.proto @@ -1003,7 +1003,8 @@ message ForwardHtlcInterceptRequest { // The requested outgoing channel id for this forwarded htlc. Because of // non-strict forwarding, this isn't necessarily the channel over which the // packet will be forwarded eventually. A different channel to the same peer - // may be selected as well. + // may be selected as well. This is set to a sentinel value (all bits set) + // if the outgoing_requested_node_id is specified for blinded routes. uint64 outgoing_requested_chan_id = 7; // The outgoing htlc amount. @@ -1025,6 +1026,19 @@ message ForwardHtlcInterceptRequest { // The custom records of the peer's incoming p2p wire message. map in_wire_custom_records = 11; + + // The requested outgoing node for a blinded forward. When non-empty, this + // field contains exactly one 33-byte compressed public key and + // outgoing_requested_chan_id is set to 18446744073709551615 + // (0xffffffffffffffff). Clients MUST NOT interpret that value as an actual + // channel ID; the presence of this field identifies a node-addressed + // forward. + // + // The possible next-hop representations are: + // node ID empty, channel ID 0: final receive; + // node ID empty, ordinary channel ID: channel-addressed forward; + // node ID present, channel ID MaxUint64: node-addressed forward. + bytes outgoing_requested_node_id = 12; } /** diff --git a/lnrpc/routerrpc/router.swagger.json b/lnrpc/routerrpc/router.swagger.json index 4fdf61663..766ea591a 100644 --- a/lnrpc/routerrpc/router.swagger.json +++ b/lnrpc/routerrpc/router.swagger.json @@ -1501,7 +1501,7 @@ "outgoing_requested_chan_id": { "type": "string", "format": "uint64", - "description": "The requested outgoing channel id for this forwarded htlc. Because of\nnon-strict forwarding, this isn't necessarily the channel over which the\npacket will be forwarded eventually. A different channel to the same peer\nmay be selected as well." + "description": "The requested outgoing channel id for this forwarded htlc. Because of\nnon-strict forwarding, this isn't necessarily the channel over which the\npacket will be forwarded eventually. A different channel to the same peer\nmay be selected as well. This is set to a sentinel value (all bits set)\nif the outgoing_requested_node_id is specified for blinded routes." }, "outgoing_amount_msat": { "type": "string", @@ -1538,6 +1538,11 @@ "format": "byte" }, "description": "The custom records of the peer's incoming p2p wire message." + }, + "outgoing_requested_node_id": { + "type": "string", + "format": "byte", + "description": "The requested outgoing node for a blinded forward. When non-empty, this\nfield contains exactly one 33-byte compressed public key and\noutgoing_requested_chan_id is set to 18446744073709551615\n(0xffffffffffffffff). Clients MUST NOT interpret that value as an actual\nchannel ID; the presence of this field identifies a node-addressed\nforward.\n\nThe possible next-hop representations are:\n node ID empty, channel ID 0: final receive;\n node ID empty, ordinary channel ID: channel-addressed forward;\n node ID present, channel ID MaxUint64: node-addressed forward." } } }, From f8d8ba6d447de0eb7f4b34669a38460c82580fe7 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Tue, 30 Jun 2026 15:38:31 +0200 Subject: [PATCH 105/134] multi: represent the blinded forwarding next hop as an fn.Either The forwarding next hop is currently always a short channel ID. To allow a blinded route to identify the next hop by node ID instead, change ForwardingInfo.NextHop to fn.Either[lnwire.ShortChannelID, [33]byte], where the Left is the outgoing channel ID and the Right (wired up in a follow-up commit) is the next node's public key. This commit is a pure representational change with no behavioural effect: every next hop is still a channel ID. The Either is encapsulated behind ForwardingInfo methods so callers never destructure it directly: IsExit() is the single source of truth for exit-hop detection (used by the link and the contract court) and NextHopChannel() yields the outgoing SCID. (cherry picked from commit d28a71765bf639bd3917d9b67cdc0afc49209a25) --- .../htlc_incoming_contest_resolver.go | 6 +-- htlcswitch/hop/forwarding_info.go | 42 +++++++++++++++++-- htlcswitch/hop/forwarding_info_test.go | 4 +- htlcswitch/hop/fuzz_test.go | 2 +- htlcswitch/hop/iterator.go | 3 +- htlcswitch/hop/iterator_test.go | 4 +- htlcswitch/hop/payload.go | 8 +++- htlcswitch/link.go | 8 ++-- htlcswitch/link_test.go | 5 ++- htlcswitch/mock.go | 15 +++++-- routing/pathfind_test.go | 10 ++++- witness_beacon.go | 2 +- 12 files changed, 83 insertions(+), 26 deletions(-) diff --git a/contractcourt/htlc_incoming_contest_resolver.go b/contractcourt/htlc_incoming_contest_resolver.go index d075166c1..b452e0e5b 100644 --- a/contractcourt/htlc_incoming_contest_resolver.go +++ b/contractcourt/htlc_incoming_contest_resolver.go @@ -83,7 +83,7 @@ func (h *htlcIncomingContestResolver) processFinalHtlcFail() error { func (h *htlcIncomingContestResolver) invalidFinalHtlc( payload *hop.Payload, height uint32) bool { - if payload.FwdInfo.NextHop != hop.Exit { + if !payload.FwdInfo.IsExit() { return false } @@ -311,7 +311,7 @@ func (h *htlcIncomingContestResolver) Resolve() (ContractResolver, error) { hodlChan <-chan interface{} witnessUpdates <-chan lntypes.Preimage ) - if payload.FwdInfo.NextHop == hop.Exit { + if payload.FwdInfo.IsExit() { // Create a buffered hodl chan to prevent deadlock. hodlQueue := queue.NewConcurrentQueue(10) hodlQueue.Start() @@ -700,7 +700,7 @@ func (h *htlcIncomingContestResolver) findAndapplyPreimage() (bool, error) { // Exit early if this is not the exit hop, which means we are not the // payment receiver and don't have the preimage. - if payload.FwdInfo.NextHop != hop.Exit { + if !payload.FwdInfo.IsExit() { return false, nil } diff --git a/htlcswitch/hop/forwarding_info.go b/htlcswitch/hop/forwarding_info.go index 539e0db1f..19589555f 100644 --- a/htlcswitch/hop/forwarding_info.go +++ b/htlcswitch/hop/forwarding_info.go @@ -2,6 +2,7 @@ package hop import ( "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/lnwire" ) @@ -11,10 +12,14 @@ import ( // received within the incoming HTLC, to ensure that the prior hop didn't // tamper with the end-to-end routing information at all. type ForwardingInfo struct { - // NextHop is the channel ID of the next hop. The received HTLC should - // be forwarded to this particular channel in order to continue the - // end-to-end route. - NextHop lnwire.ShortChannelID + // NextHop identifies the next hop the HTLC should be forwarded to. In + // the common case it is a Left holding the short channel ID of the + // outgoing channel. For a blinded route whose recipient identifies the + // next hop by node ID (next_node_id) it is a Right holding the next + // node's compressed public key, which the switch's non-strict + // forwarding logic resolves to one of our channels with that peer. The + // zero value is a Left equal to hop.Exit, which denotes the exit hop. + NextHop fn.Either[lnwire.ShortChannelID, [33]byte] // AmountToForward is the amount of milli-satoshis that the receiving // node should forward to the next hop. @@ -35,6 +40,35 @@ type ForwardingInfo struct { PathID *chainhash.Hash } +// NewChannelNextHop returns a next-hop value that identifies the outgoing +// channel by its short channel ID, which is the common case. +func NewChannelNextHop( + scid lnwire.ShortChannelID) fn.Either[lnwire.ShortChannelID, [33]byte] { + + return fn.NewLeft[lnwire.ShortChannelID, [33]byte](scid) +} + +// IsExit returns true if this forwarding info denotes the exit hop, i.e. we are +// the final recipient of the HTLC. This is the case when the next hop is a +// short channel ID equal to hop.Exit. A node-ID next hop (used by some blinded +// routes) is always a forward, never the exit hop. +func (f ForwardingInfo) IsExit() bool { + var isExit bool + f.NextHop.WhenLeft(func(scid lnwire.ShortChannelID) { + isExit = scid == Exit + }) + + return isExit +} + +// NextHopChannel returns the short channel ID of the outgoing channel when the +// next hop is identified by channel ID (the common case). It returns None when +// the next hop is identified by node ID instead, in which case the outgoing +// channel is selected by the switch's non-strict forwarding. +func (f ForwardingInfo) NextHopChannel() fn.Option[lnwire.ShortChannelID] { + return f.NextHop.LeftToSome() +} + // FinalHtlcValidationResult describes the result of checking a final-hop // HTLC against the onion payload and supported final-hop CLTV range. type FinalHtlcValidationResult uint8 diff --git a/htlcswitch/hop/forwarding_info_test.go b/htlcswitch/hop/forwarding_info_test.go index 82a5ad0c6..284c7c3bd 100644 --- a/htlcswitch/hop/forwarding_info_test.go +++ b/htlcswitch/hop/forwarding_info_test.go @@ -21,7 +21,7 @@ func TestValidateFinalHtlc(t *testing.T) { fwdInfo := ForwardingInfo{ AmountToForward: amount, OutgoingCLTV: expiry, - NextHop: Exit, + NextHop: NewChannelNextHop(Exit), } testCases := []struct { @@ -115,7 +115,7 @@ func TestValidateFinalHtlc(t *testing.T) { fwdInfo: ForwardingInfo{ AmountToForward: amount, OutgoingCLTV: expiry + maxCltvDelta + 2, - NextHop: Exit, + NextHop: NewChannelNextHop(Exit), }, validateAmount: true, expected: FinalHtlcInvalidCltv, diff --git a/htlcswitch/hop/fuzz_test.go b/htlcswitch/hop/fuzz_test.go index 525194c38..853292bac 100644 --- a/htlcswitch/hop/fuzz_test.go +++ b/htlcswitch/hop/fuzz_test.go @@ -92,7 +92,7 @@ func hopFromPayload(p *Payload) (*route.Hop, uint64) { BlindingPoint: p.blindingPoint, CustomRecords: p.customRecords, TotalAmtMsat: p.totalAmtMsat, - }, p.FwdInfo.NextHop.ToUint64() + }, p.FwdInfo.NextHop.UnwrapLeftOr(Exit).ToUint64() } // FuzzPayloadFinal fuzzes final hop payloads, providing the additional context diff --git a/htlcswitch/hop/iterator.go b/htlcswitch/hop/iterator.go index cf04b88a1..7240f2d85 100644 --- a/htlcswitch/hop/iterator.go +++ b/htlcswitch/hop/iterator.go @@ -324,8 +324,9 @@ func deriveBlindedRouteForwardingInfo(r *sphinxHopIterator, if err != nil { return nil, routeRole, err } + payload.FwdInfo = ForwardingInfo{ - NextHop: nextSCID.Val, + NextHop: NewChannelNextHop(nextSCID.Val), AmountToForward: fwdAmt, OutgoingCLTV: r.blindingKit.IncomingCltv - uint32( relayInfo.Val.CltvExpiryDelta, diff --git a/htlcswitch/hop/iterator_test.go b/htlcswitch/hop/iterator_test.go index b132a046d..1acd39079 100644 --- a/htlcswitch/hop/iterator_test.go +++ b/htlcswitch/hop/iterator_test.go @@ -33,7 +33,9 @@ func TestSphinxHopIteratorForwardingInstructions(t *testing.T) { // extract each type, no matter the payload type. nextAddrInt := binary.BigEndian.Uint64(hopData.NextAddress[:]) expectedFwdInfo := ForwardingInfo{ - NextHop: lnwire.NewShortChanIDFromInt(nextAddrInt), + NextHop: NewChannelNextHop( + lnwire.NewShortChanIDFromInt(nextAddrInt), + ), AmountToForward: lnwire.MilliSatoshi(hopData.ForwardAmount), OutgoingCLTV: hopData.OutgoingCltv, } diff --git a/htlcswitch/hop/payload.go b/htlcswitch/hop/payload.go index 14a0813e8..c84f1d2a8 100644 --- a/htlcswitch/hop/payload.go +++ b/htlcswitch/hop/payload.go @@ -126,7 +126,9 @@ func NewLegacyPayload(f *sphinx.HopData) *Payload { return &Payload{ FwdInfo: ForwardingInfo{ - NextHop: lnwire.NewShortChanIDFromInt(nextHop), + NextHop: NewChannelNextHop( + lnwire.NewShortChanIDFromInt(nextHop), + ), AmountToForward: lnwire.MilliSatoshi(f.ForwardAmount), OutgoingCLTV: f.OutgoingCltv, }, @@ -201,7 +203,9 @@ func ParseTLVPayload(r io.Reader) (*Payload, map[tlv.Type][]byte, error) { return &Payload{ FwdInfo: ForwardingInfo{ - NextHop: lnwire.NewShortChanIDFromInt(cid), + NextHop: NewChannelNextHop( + lnwire.NewShortChanIDFromInt(cid), + ), AmountToForward: lnwire.MilliSatoshi(amt), OutgoingCLTV: cltv, }, diff --git a/htlcswitch/link.go b/htlcswitch/link.go index f966cd3e2..a44f7132b 100644 --- a/htlcswitch/link.go +++ b/htlcswitch/link.go @@ -3156,8 +3156,8 @@ func (l *channelLink) processRemoteAdds(fwdPkg *channeldb.FwdPkg) { continue } - switch fwdInfo.NextHop { - case hop.Exit: + switch { + case fwdInfo.IsExit(): err := l.processExitHop( add, sourceRef, obfuscator, fwdInfo, heightNow, pld, @@ -3235,7 +3235,7 @@ func (l *channelLink) processRemoteAdds(fwdPkg *channeldb.FwdPkg) { updatePacket := &htlcPacket{ incomingChanID: l.ShortChanID(), incomingHTLCID: add.ID, - outgoingChanID: fwdInfo.NextHop, + outgoingChanID: fwdInfo.NextHopChannel().UnwrapOr(hop.Exit), sourceRef: &sourceRef, incomingAmount: add.Amount, amount: outgoingAdd.Amount, @@ -3312,7 +3312,7 @@ func (l *channelLink) processRemoteAdds(fwdPkg *channeldb.FwdPkg) { updatePacket := &htlcPacket{ incomingChanID: l.ShortChanID(), incomingHTLCID: add.ID, - outgoingChanID: fwdInfo.NextHop, + outgoingChanID: fwdInfo.NextHopChannel().UnwrapOr(hop.Exit), sourceRef: &sourceRef, incomingAmount: add.Amount, amount: addMsg.Amount, diff --git a/htlcswitch/link_test.go b/htlcswitch/link_test.go index 991819b77..59b3acd07 100644 --- a/htlcswitch/link_test.go +++ b/htlcswitch/link_test.go @@ -777,8 +777,9 @@ func testChannelLinkInboundFee(t *testing.T, //nolint:thelper hops := []*hop.Payload{ { FwdInfo: hop.ForwardingInfo{ - NextHop: n.carolChannelLink. - ShortChanID(), + NextHop: hop.NewChannelNextHop( + n.carolChannelLink.ShortChanID(), + ), AmountToForward: 1_000_000, OutgoingCLTV: 106, }, diff --git a/htlcswitch/mock.go b/htlcswitch/mock.go index dbab96727..62cbb8822 100644 --- a/htlcswitch/mock.go +++ b/htlcswitch/mock.go @@ -367,7 +367,13 @@ func (r *mockHopIterator) EncodeNextHop(w io.Writer) error { } func encodeFwdInfo(w io.Writer, f *hop.ForwardingInfo) error { - if err := binary.Write(w, binary.BigEndian, f.NextHop); err != nil { + if f.NextHop.IsRight() { + return fmt.Errorf("mock serialization does not support " + + "node-ID next hop") + } + + nextHop := f.NextHopChannel().UnwrapOr(hop.Exit) + if err := binary.Write(w, binary.BigEndian, nextHop); err != nil { return err } @@ -509,7 +515,8 @@ func (p *mockIteratorDecoder) DecodeHopIterator(r io.Reader, rHash []byte, } var nextHopBytes [8]byte - binary.BigEndian.PutUint64(nextHopBytes[:], f.NextHop.ToUint64()) + scid := f.NextHopChannel().UnwrapOr(hop.Exit) + binary.BigEndian.PutUint64(nextHopBytes[:], scid.ToUint64()) hops[i] = hop.NewLegacyPayload(&sphinx.HopData{ Realm: [1]byte{}, // hop.BitcoinNetwork @@ -562,9 +569,11 @@ func (p *mockIteratorDecoder) DecodeHopIterators(id []byte, } func decodeFwdInfo(r io.Reader, f *hop.ForwardingInfo) error { - if err := binary.Read(r, binary.BigEndian, &f.NextHop); err != nil { + var nextHop lnwire.ShortChannelID + if err := binary.Read(r, binary.BigEndian, &nextHop); err != nil { return err } + f.NextHop = hop.NewChannelNextHop(nextHop) if err := binary.Read(r, binary.BigEndian, &f.AmountToForward); err != nil { return err diff --git a/routing/pathfind_test.go b/routing/pathfind_test.go index 77bad02e3..a8da3f660 100644 --- a/routing/pathfind_test.go +++ b/routing/pathfind_test.go @@ -1170,7 +1170,9 @@ func testBasicGraphPathFindingCase(t *testing.T, graphInstance *testGraphInstanc require.Equal( t, route.Hops[i+1].ChannelID, - payload.FwdInfo.NextHop.ToUint64(), + payload.FwdInfo.NextHopChannel().UnwrapOr( + switchhop.Exit, + ).ToUint64(), ) } @@ -1183,7 +1185,11 @@ func testBasicGraphPathFindingCase(t *testing.T, graphInstance *testGraphInstanc // The final hop should have a next hop value of all zeroes in order // to indicate it's the exit hop. - require.Zero(t, payload.FwdInfo.NextHop.ToUint64()) + require.Zero( + t, payload.FwdInfo.NextHopChannel().UnwrapOr( + switchhop.Exit, + ).ToUint64(), + ) var expectedTotalFee lnwire.MilliSatoshi for i := 0; i < expectedHopCount; i++ { diff --git a/witness_beacon.go b/witness_beacon.go index 68c096a85..550a38adc 100644 --- a/witness_beacon.go +++ b/witness_beacon.go @@ -113,7 +113,7 @@ func (p *preimageBeacon) SubscribeUpdates( IncomingExpiry: htlc.RefundTimeout, IncomingAmount: htlc.Amt, IncomingCircuit: inKey, - OutgoingChanID: payload.FwdInfo.NextHop, + OutgoingChanID: payload.FwdInfo.NextHopChannel().UnwrapOr(hop.Exit), OutgoingExpiry: payload.FwdInfo.OutgoingCLTV, OutgoingAmount: payload.FwdInfo.AmountToForward, InOnionCustomRecords: payload.CustomRecords(), From 97200d56101ad3a76f668dc91036a9dc968582ef Mon Sep 17 00:00:00 2001 From: bitromortac Date: Wed, 8 Jul 2026 13:13:44 +0000 Subject: [PATCH 106/134] htlcswitch/hop: decode next_node_id blinded hops Some implementations (e.g. Core Lightning) identify the next hop in a blinded route by the next node's ID (next_node_id) instead of a short channel ID. Decode such a hop into a node-ID next hop, the Right of ForwardingInfo.NextHop, holding the next node's public key. The switch resolves that key to one of our channels with the peer in a later commit. BOLT 4 requires a non-final blinded hop to carry exactly one of short_channel_id or next_node_id, so a hop that sets both is rejected. (cherry picked from commit 4fd4289a08c34024c44747182b7c668e604e86fd) --- htlcswitch/hop/forwarding_info.go | 16 ++ htlcswitch/hop/forwarding_info_test.go | 39 +++ htlcswitch/hop/iterator.go | 40 ++- htlcswitch/hop/iterator_test.go | 376 +++++++++++++++++++++++++ record/blinded_data.go | 4 +- 5 files changed, 468 insertions(+), 7 deletions(-) diff --git a/htlcswitch/hop/forwarding_info.go b/htlcswitch/hop/forwarding_info.go index 19589555f..e550035a1 100644 --- a/htlcswitch/hop/forwarding_info.go +++ b/htlcswitch/hop/forwarding_info.go @@ -48,6 +48,15 @@ func NewChannelNextHop( return fn.NewLeft[lnwire.ShortChannelID, [33]byte](scid) } +// NewNodeNextHop returns a next-hop value that identifies the next hop by the +// next node's compressed public key, as used by blinded routes that set +// next_node_id instead of a short channel ID. +func NewNodeNextHop( + nodeID [33]byte) fn.Either[lnwire.ShortChannelID, [33]byte] { + + return fn.NewRight[lnwire.ShortChannelID, [33]byte](nodeID) +} + // IsExit returns true if this forwarding info denotes the exit hop, i.e. we are // the final recipient of the HTLC. This is the case when the next hop is a // short channel ID equal to hop.Exit. A node-ID next hop (used by some blinded @@ -69,6 +78,13 @@ func (f ForwardingInfo) NextHopChannel() fn.Option[lnwire.ShortChannelID] { return f.NextHop.LeftToSome() } +// NextHopNode returns the next hop's compressed pubkey when it is identified by +// node ID (blinded routes via next_node_id), or None when identified by +// channel. +func (f ForwardingInfo) NextHopNode() fn.Option[[33]byte] { + return f.NextHop.RightToSome() +} + // FinalHtlcValidationResult describes the result of checking a final-hop // HTLC against the onion payload and supported final-hop CLTV range. type FinalHtlcValidationResult uint8 diff --git a/htlcswitch/hop/forwarding_info_test.go b/htlcswitch/hop/forwarding_info_test.go index 284c7c3bd..3ca5fbe3d 100644 --- a/htlcswitch/hop/forwarding_info_test.go +++ b/htlcswitch/hop/forwarding_info_test.go @@ -3,6 +3,7 @@ package hop import ( "testing" + "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/lnwire" "github.com/stretchr/testify/require" ) @@ -135,3 +136,41 @@ func TestValidateFinalHtlc(t *testing.T) { }) } } + +// TestForwardingInfoNextHop asserts the next-hop accessors for both the short +// channel ID (Left) and node ID (Right) representations, including the +// invariant that the zero-value ForwardingInfo denotes the exit hop. +func TestForwardingInfoNextHop(t *testing.T) { + t.Parallel() + + scid := lnwire.NewShortChanIDFromInt(12345) + nodeID := [33]byte{0x02} + + // The zero-value ForwardingInfo must denote the exit hop, since its + // NextHop is a Left equal to hop.Exit. Callers rely on this to detect + // that we are the final recipient. + zero := ForwardingInfo{} + require.True(t, zero.IsExit(), "zero value must be the exit hop") + require.Equal( + t, fn.Some(Exit), zero.NextHopChannel(), + "zero value must expose the Exit channel", + ) + + // An explicit channel next hop equal to Exit is likewise the exit hop. + exit := ForwardingInfo{NextHop: NewChannelNextHop(Exit)} + require.True(t, exit.IsExit()) + + // A channel next hop with a real SCID is a forward, and exposes that + // SCID through NextHopChannel. + channel := ForwardingInfo{NextHop: NewChannelNextHop(scid)} + require.False(t, channel.IsExit()) + require.Equal(t, fn.Some(scid), channel.NextHopChannel()) + + // A node-ID next hop is always a forward and never exposes an outgoing + // channel, since the switch selects one via non-strict forwarding. + node := ForwardingInfo{NextHop: NewNodeNextHop(nodeID)} + require.False(t, node.IsExit()) + require.Equal( + t, fn.None[lnwire.ShortChannelID](), node.NextHopChannel(), + ) +} diff --git a/htlcswitch/hop/iterator.go b/htlcswitch/hop/iterator.go index 7240f2d85..6ecd998fc 100644 --- a/htlcswitch/hop/iterator.go +++ b/htlcswitch/hop/iterator.go @@ -10,6 +10,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/chaincfg/chainhash" sphinx "github.com/lightningnetwork/lightning-onion" + "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/record" "github.com/lightningnetwork/lnd/tlv" @@ -231,6 +232,13 @@ func parseAndValidateRecipientData(r *sphinxHopIterator, payload *Payload, return nil, routeRole, err } + // BOLT 4 requires a blinded hop to set exactly one of short_channel_id + // or next_node_id. Reject a hop that sets both here. + if routeData.ShortChannelID.IsSome() && routeData.NextNodeID.IsSome() { + return nil, routeRole, fmt.Errorf("blinded hop sets both " + + "short channel ID and next node ID") + } + // This is the final node in the blinded route. if isFinal { return deriveBlindedRouteFinalHopForwardingInfo( @@ -318,15 +326,35 @@ func deriveBlindedRouteForwardingInfo(r *sphinxHopIterator, ) } - nextSCID, err := routeData.ShortChannelID.UnwrapOrErr( - fmt.Errorf("next SCID not set for non-final blinded hop"), - ) - if err != nil { - return nil, routeRole, err + // Determine the next hop. The recipient identifies it either by a short + // channel ID (the common case) or, as some implementations do for + // blinded routes, by the next node's ID (next_node_id). Setting both is + // already rejected upstream, and the dummy hop check above has handled + // a next_node_id that points at us. + var nextHop fn.Either[lnwire.ShortChannelID, [33]byte] + switch { + case routeData.ShortChannelID.IsSome(): + scid := routeData.ShortChannelID.UnwrapOr( + routeData.ShortChannelID.Zero(), + ) + nextHop = NewChannelNextHop(scid.Val) + + case routeData.NextNodeID.IsSome(): + nodeID := routeData.NextNodeID.UnwrapOr( + routeData.NextNodeID.Zero(), + ) + var pubKey [33]byte + copy(pubKey[:], nodeID.Val.SerializeCompressed()) + + nextHop = NewNodeNextHop(pubKey) + + default: + return nil, routeRole, fmt.Errorf("next hop not set for " + + "non-final blinded hop") } payload.FwdInfo = ForwardingInfo{ - NextHop: NewChannelNextHop(nextSCID.Val), + NextHop: nextHop, AmountToForward: fwdAmt, OutgoingCLTV: r.blindingKit.IncomingCltv - uint32( relayInfo.Val.CltvExpiryDelta, diff --git a/htlcswitch/hop/iterator_test.go b/htlcswitch/hop/iterator_test.go index 1acd39079..3d30faefc 100644 --- a/htlcswitch/hop/iterator_test.go +++ b/htlcswitch/hop/iterator_test.go @@ -9,6 +9,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/davecgh/go-spew/spew" sphinx "github.com/lightningnetwork/lightning-onion" + "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/record" "github.com/lightningnetwork/lnd/tlv" @@ -305,3 +306,378 @@ func TestParseAndValidateRecipientData(t *testing.T) { }) } } + +// TestDeriveBlindedRouteNextHop asserts how a non-final blinded hop's next hop +// is derived from the recipient data: a short channel ID becomes a Left, a +// next_node_id becomes a Right, having both set is rejected with an error, and +// the absence of both is also an error. +func TestDeriveBlindedRouteNextHop(t *testing.T) { + t.Parallel() + + nodeKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + nextNodeKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + nextNodePub := nextNodeKey.PubKey() + + var nextNodeRaw [33]byte + copy(nextNodeRaw[:], nextNodePub.SerializeCompressed()) + + scid := lnwire.NewShortChanIDFromInt(1500) + + relayInfo := tlv.SomeRecordT(tlv.NewRecordT[tlv.TlvType10]( + record.PaymentRelayInfo{ + CltvExpiryDelta: 10, + BaseFee: 100, + FeeRate: 0, + }, + )) + constraints := tlv.SomeRecordT(tlv.NewRecordT[tlv.TlvType12]( + record.PaymentConstraints{ + MaxCltvExpiry: 1000, + HtlcMinimumMsat: lnwire.MilliSatoshi(1), + }, + )) + scidRecord := tlv.SomeRecordT(tlv.NewRecordT[tlv.TlvType2](scid)) + nodeIDRecord := tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType4](nextNodePub), + ) + + tests := []struct { + name string + data *record.BlindedRouteData + expectedHop fn.Either[lnwire.ShortChannelID, [33]byte] + expectedErr string + }{ + { + name: "short channel id only", + data: &record.BlindedRouteData{ + ShortChannelID: scidRecord, + RelayInfo: relayInfo, + Constraints: constraints, + }, + expectedHop: NewChannelNextHop(scid), + }, + { + name: "next node id only", + data: &record.BlindedRouteData{ + NextNodeID: nodeIDRecord, + RelayInfo: relayInfo, + Constraints: constraints, + }, + expectedHop: NewNodeNextHop(nextNodeRaw), + }, + { + // BOLT 4 requires a non-final blinded hop to set + // exactly one of short_channel_id or next_node_id, so + // setting both must be rejected. + name: "both present is an error", + data: &record.BlindedRouteData{ + ShortChannelID: scidRecord, + NextNodeID: nodeIDRecord, + RelayInfo: relayInfo, + Constraints: constraints, + }, + expectedErr: "both short channel ID and next node ID", + }, + { + name: "neither present", + data: &record.BlindedRouteData{ + RelayInfo: relayInfo, + Constraints: constraints, + }, + expectedErr: "next hop not set", + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + data, err := record.EncodeBlindedRouteData( + testCase.data, + ) + require.NoError(t, err) + + kit := BlindingKit{ + Processor: &mockProcessor{}, + IncomingAmount: 10000, + IncomingCltv: 500, + UpdateAddBlinding: tlv.SomeRecordT( + //nolint:ll + tlv.NewPrimitiveRecord[lnwire.BlindingPointTlvType](&btcec.PublicKey{}), + ), + } + iterator := &sphinxHopIterator{ + blindingKit: kit, + router: sphinx.NewRouter( + &sphinx.PrivKeyECDH{PrivKey: nodeKey}, + sphinx.NewMemoryReplayLog(), + ), + } + + payload, _, err := parseAndValidateRecipientData( + iterator, &Payload{encryptedData: data}, + false, RouteRoleCleartext, + ) + + if testCase.expectedErr != "" { + require.ErrorContains( + t, err, testCase.expectedErr, + ) + + return + } + + require.NoError(t, err) + require.Equal( + t, testCase.expectedHop, + payload.FwdInfo.NextHop, + ) + }) + } +} + +// TestBlindedHopBothNextHopFieldsRejected asserts that a blinded hop setting +// both short_channel_id and next_node_id is rejected for a final hop and for a +// dummy hop (next_node_id == our own pubkey), not just an intermediate hop. The +// mutual-exclusivity check runs before the final-hop and dummy-hop branches, so +// none of them accept a hop that violates BOLT 4. The intermediate case is +// already covered by TestDeriveBlindedRouteNextHop. +func TestBlindedHopBothNextHopFieldsRejected(t *testing.T) { + t.Parallel() + + nodeKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + nodePub := nodeKey.PubKey() + + // Route data that sets both short_channel_id and next_node_id. The node + // ID is our own pubkey, which for a non-final hop would otherwise + // signal a dummy hop; the both-set check must still fire first. + bothData := &record.BlindedRouteData{ + ShortChannelID: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType2]( + lnwire.NewShortChanIDFromInt(1500), + ), + ), + NextNodeID: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType4](nodePub), + ), + RelayInfo: tlv.SomeRecordT(tlv.NewRecordT[tlv.TlvType10]( + record.PaymentRelayInfo{ + CltvExpiryDelta: 10, + BaseFee: 100, + FeeRate: 0, + }, + )), + Constraints: tlv.SomeRecordT(tlv.NewRecordT[tlv.TlvType12]( + record.PaymentConstraints{ + MaxCltvExpiry: 1000, + HtlcMinimumMsat: lnwire.MilliSatoshi(1), + }, + )), + } + data, err := record.EncodeBlindedRouteData(bothData) + require.NoError(t, err) + + // Both the dummy/forwarding path (isFinal=false, next_node_id points at + // us) and the final path (isFinal=true) must reject the hop. + for _, isFinal := range []bool{false, true} { + name := "forwarding hop" + if isFinal { + name = "final hop" + } + + t.Run(name, func(t *testing.T) { + kit := BlindingKit{ + Processor: &mockProcessor{}, + IncomingAmount: 10000, + IncomingCltv: 500, + UpdateAddBlinding: tlv.SomeRecordT( + //nolint:ll + tlv.NewPrimitiveRecord[lnwire.BlindingPointTlvType](&btcec.PublicKey{}), + ), + } + iterator := &sphinxHopIterator{ + blindingKit: kit, + router: sphinx.NewRouter( + &sphinx.PrivKeyECDH{PrivKey: nodeKey}, + sphinx.NewMemoryReplayLog(), + ), + } + + _, _, err := parseAndValidateRecipientData( + iterator, &Payload{encryptedData: data}, + isFinal, RouteRoleCleartext, + ) + require.ErrorContains( + t, err, + "both short channel ID and next node ID", + ) + }) + } +} + +// TestBlindedRouteDummyHopPeeledLocally asserts that a blinded route hop where +// next_node_id is our own public key is recognized as a dummy hop and is peeled +// locally rather than falling through to the generic next_node_id forwarding +// branch. +func TestBlindedRouteDummyHopPeeledLocally(t *testing.T) { + t.Parallel() + + // Construct a realistic onion packet that contains a blinded final hop. + // We'll use this to test that we can peel a dummy hop locally and + // extract the forwarding information from the decrypted final hop's + // payload. + nodeKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + nodePub := nodeKey.PubKey() + + relayInfo := tlv.SomeRecordT(tlv.NewRecordT[tlv.TlvType10]( + record.PaymentRelayInfo{ + CltvExpiryDelta: 10, + BaseFee: 100, + FeeRate: 0, + }, + )) + constraints := tlv.SomeRecordT(tlv.NewRecordT[tlv.TlvType12]( + record.PaymentConstraints{ + MaxCltvExpiry: 1000, + HtlcMinimumMsat: lnwire.MilliSatoshi(1), + }, + )) + + // Set next_node_id to our own public key. This signals a dummy hop. + nodeIDRecord := tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType4](nodePub), + ) + + // We'll generate a valid, cryptographically blinded final hop's payload + // using sphinx.BuildBlindedPath. This contains the PathID. + secret := make([]byte, 32) + secret[0] = 2 + finalHopData := &record.BlindedRouteData{ + PathID: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType6](secret), + ), + } + finalHopDataBytes, err := record.EncodeBlindedRouteData(finalHopData) + require.NoError(t, err) + + hopInfo := &sphinx.HopInfo{ + NodePub: nodePub, + PlainText: finalHopDataBytes, + } + + blindingSessionKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + blindedPathInfo, err := sphinx.BuildBlindedPath( + blindingSessionKey, []*sphinx.HopInfo{hopInfo}, + ) + require.NoError(t, err) + + // Since we are peeling a dummy hop locally, we want the next blinding + // override to be the blinding point generated for our blinded final + // hop. + dummyHopData := &record.BlindedRouteData{ + NextNodeID: nodeIDRecord, + RelayInfo: relayInfo, + Constraints: constraints, + NextBlindingOverride: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType8]( + blindedPathInfo.Path.BlindingPoint, + ), + ), + } + + data, err := record.EncodeBlindedRouteData(dummyHopData) + require.NoError(t, err) + + // Encode a valid TLV payload for the next hop (which we will peel). + var hop2Buffer bytes.Buffer + amt := uint64(10000) + cltv := uint32(500) + encryptedDataRecord := record.NewEncryptedDataRecord( + &blindedPathInfo.Path.BlindedHops[0].CipherText, + ) + tlvRecords := []tlv.Record{ + record.NewAmtToFwdRecord(&amt), + record.NewLockTimeRecord(&cltv), + encryptedDataRecord, + } + tlvStream, err := tlv.NewStream(tlvRecords...) + require.NoError(t, err) + err = tlvStream.Encode(&hop2Buffer) + require.NoError(t, err) + + hopPayload, err := sphinx.NewTLVHopPayload(hop2Buffer.Bytes()) + require.NoError(t, err) + + // Create a valid 1-hop onion path using our blinded public key. + var paymentPath sphinx.PaymentPath + paymentPath[0] = sphinx.OnionHop{ + NodePub: *blindedPathInfo.Path.BlindedHops[0].BlindedNodePub, + HopPayload: hopPayload, + } + + sessionKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + rHash := [32]byte{1} + + // Generate a cryptographically valid onion packet for this path. + onionPacket, err := sphinx.NewOnionPacket( + &paymentPath, sessionKey, rHash[:], + sphinx.DeterministicPacketFiller, + ) + require.NoError(t, err) + + // Simulate an incoming HTLC with a blinding point and a valid onion + // packet. The blinding point is used to decrypt the dummy hop's + // payload, which contains the blinding point for the next hop (the + // blinded final hop). + kit := BlindingKit{ + Processor: &mockProcessor{}, + IncomingAmount: 12000, + IncomingCltv: 510, + UpdateAddBlinding: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[lnwire.BlindingPointTlvType]( + nodePub, + ), + ), + } + + iterator := &sphinxHopIterator{ + blindingKit: kit, + rHash: rHash[:], + router: sphinx.NewRouter( + &sphinx.PrivKeyECDH{PrivKey: nodeKey}, + sphinx.NewMemoryReplayLog(), + ), + // Set our valid onion packet to be peeled. + processedPacket: &sphinx.ProcessedPacket{ + NextPacket: onionPacket, + }, + } + + // When we parse and validate the recipient data, it should enter the + // dummy-hop peeling path. Since our onion packet is valid and matches + // our private key, it should be successfully peeled and parsed. + pld, _, err := parseAndValidateRecipientData( + iterator, &Payload{encryptedData: data}, + false, RouteRoleCleartext, + ) + + // Assert that we successfully peeled the dummy hop and extracted the + // decrypted final payload. + require.NoError(t, err) + require.NotNil(t, pld) + + fwdInfo := pld.ForwardingInfo() + require.Equal(t, lnwire.MilliSatoshi(0), fwdInfo.AmountToForward) + require.Equal(t, uint32(0), fwdInfo.OutgoingCLTV) + require.NotNil(t, fwdInfo.PathID) + require.Equal(t, secret, fwdInfo.PathID[:]) +} diff --git a/record/blinded_data.go b/record/blinded_data.go index 3d9b17c27..31e5e9ad7 100644 --- a/record/blinded_data.go +++ b/record/blinded_data.go @@ -31,7 +31,9 @@ type BlindedRouteData struct { // NextNodeID is the node ID of the next node on the path. In the // context of blinded path payments, this is used to indicate the - // presence of dummy hops that need to be peeled from the onion. + // presence of dummy hops that need to be peeled from the onion, or to + // identify a real next-node forwarding target when the public key is + // not ours. NextNodeID tlv.OptionalRecordT[tlv.TlvType4, *btcec.PublicKey] // PathID is a secret set of bytes that the blinded path creator will From 9a2c8686655469c765f408593914aeefd143dd11 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Wed, 8 Jul 2026 13:13:44 +0000 Subject: [PATCH 107/134] htlcswitch: forward node-ID blinded hops via non-strict forwarding Fixes lightningnetwork/lnd#10937: forward a blinded-route payment when the recipient identifies the next hop by node ID rather than a short channel ID. The htlcPacket carries the decoded next hop to the switch, whose handlePacketAdd resolves the pubkey to the peer's links via getLinks() and lets the existing non-strict forwarding logic load-balance across the peer's channels. outgoingChanID stays a ShortChannelID. It is the persisted CircuitKey and is set to the selected channel after non-strict selection. The circular route check filters candidate channels before selection. (cherry picked from commit dbc5704070a11c24694598b2101796e8a88348ec) --- htlcswitch/link.go | 2 + htlcswitch/mailbox.go | 20 ++++-- htlcswitch/mailbox_test.go | 71 ++++++++++++++++++++ htlcswitch/packet.go | 26 ++++++-- htlcswitch/switch.go | 123 ++++++++++++++++++++++++++-------- htlcswitch/switch_test.go | 133 +++++++++++++++++++++++++++++++++++++ 6 files changed, 334 insertions(+), 41 deletions(-) diff --git a/htlcswitch/link.go b/htlcswitch/link.go index a44f7132b..437127919 100644 --- a/htlcswitch/link.go +++ b/htlcswitch/link.go @@ -3236,6 +3236,7 @@ func (l *channelLink) processRemoteAdds(fwdPkg *channeldb.FwdPkg) { incomingChanID: l.ShortChanID(), incomingHTLCID: add.ID, outgoingChanID: fwdInfo.NextHopChannel().UnwrapOr(hop.Exit), + outgoingHop: fwdInfo.NextHop, sourceRef: &sourceRef, incomingAmount: add.Amount, amount: outgoingAdd.Amount, @@ -3313,6 +3314,7 @@ func (l *channelLink) processRemoteAdds(fwdPkg *channeldb.FwdPkg) { incomingChanID: l.ShortChanID(), incomingHTLCID: add.ID, outgoingChanID: fwdInfo.NextHopChannel().UnwrapOr(hop.Exit), + outgoingHop: fwdInfo.NextHop, sourceRef: &sourceRef, incomingAmount: add.Amount, amount: addMsg.Amount, diff --git a/htlcswitch/mailbox.go b/htlcswitch/mailbox.go index b283825dd..2a0796855 100644 --- a/htlcswitch/mailbox.go +++ b/htlcswitch/mailbox.go @@ -699,12 +699,18 @@ func (m *memoryMailBox) FailAdd(pkt *htlcPacket) { reason lnwire.OpaqueReason ) - // Create a temporary channel failure which we will send back to our - // peer if this is a forward, or report to the user if the failed - // payment was locally initiated. - failure := m.cfg.failMailboxUpdate( - pkt.originalOutgoingChanID, m.cfg.shortChanID, - ) + var failure lnwire.FailureMessage + if pkt.outgoingHop.IsRight() { + // A node-ID next hop has no requested outgoing channel. + // Returning a channel_update could leak a private channel's + // SCID if the failure reason is persisted before blinding + // error processing or replayed during channel reestablishment. + failure = &lnwire.FailUnknownNextPeer{} + } else { + failure = m.cfg.failMailboxUpdate( + pkt.originalOutgoingChanID, m.cfg.shortChanID, + ) + } // If the payment was locally initiated (which is indicated by a nil // obfuscator), we do not need to encrypt it back to the sender. @@ -737,6 +743,8 @@ func (m *memoryMailBox) FailAdd(pkt *htlcPacket) { failPkt := &htlcPacket{ incomingChanID: pkt.incomingChanID, incomingHTLCID: pkt.incomingHTLCID, + outgoingChanID: pkt.outgoingChanID, + outgoingHop: pkt.outgoingHop, circuit: pkt.circuit, sourceRef: pkt.sourceRef, hasSource: true, diff --git a/htlcswitch/mailbox_test.go b/htlcswitch/mailbox_test.go index 57a581c4b..8b0967a1a 100644 --- a/htlcswitch/mailbox_test.go +++ b/htlcswitch/mailbox_test.go @@ -10,6 +10,7 @@ import ( "github.com/davecgh/go-spew/spew" "github.com/lightningnetwork/lnd/channeldb" "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/lnmock" "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/lightningnetwork/lnd/lnwire" @@ -276,6 +277,17 @@ func (c *mailboxContext) sendAdds(start, num int) []*htlcPacket { ID: uint64(start + i), }, } + if i%2 == 0 { + pkt.outgoingHop = fn.NewLeft[ + lnwire.ShortChannelID, [33]byte, + ](pkt.outgoingChanID) + } else { + var nodeID [33]byte + prand.Read(nodeID[:]) + pkt.outgoingHop = fn.NewRight[ + lnwire.ShortChannelID, [33]byte, + ](nodeID) + } sentPackets[i] = pkt err := c.mailbox.AddPacket(pkt) @@ -313,6 +325,14 @@ func (c *mailboxContext) checkFails(adds []*htlcPacket) { select { case fail := <-c.forwards: if add.inKey() == fail.inKey() { + require.Equal( + c.t, add.outgoingChanID, + fail.outgoingChanID, + ) + require.Equal( + c.t, add.outgoingHop, + fail.outgoingHop, + ) continue } c.t.Fatalf("inkey mismatch #%d, add: %v vs fail: %v", @@ -828,3 +848,54 @@ func TestMailOrchestrator(t *testing.T) { spew.Sdump(sentPackets), spew.Sdump(recvdPackets)) } } + +// TestMailBoxFailAddNodeID asserts that FailAdd for a node-ID hop returns a +// FailUnknownNextPeer failure without a channel update. +func TestMailBoxFailAddNodeID(t *testing.T) { + ctx := newMailboxContext(t, time.Now(), time.Minute) + + var nodeID [33]byte + nodeID[0] = 0x02 + + pkt := &htlcPacket{ + incomingChanID: lnwire.NewShortChanIDFromInt(1), + incomingHTLCID: 1, + outgoingHop: fn.NewRight[lnwire.ShortChannelID, [33]byte]( + nodeID, + ), + htlc: &lnwire.UpdateAddHTLC{ + ID: 1, + }, + } + + require.NoError(t, ctx.mailbox.AddPacket(pkt)) + + // Pull packet from mailbox to simulate link delivery. + select { + case <-ctx.mailbox.PacketOutBox(): + case <-time.After(50 * time.Millisecond): + t.Fatal("timeout waiting for packet outbox") + } + + // Fail the packet via FailAdd. + ctx.mailbox.FailAdd(pkt) + + select { + case pktResponse := <-ctx.forwards: + require.Equal(t, pkt.incomingChanID, pktResponse.incomingChanID) + require.Equal(t, pkt.incomingHTLCID, pktResponse.incomingHTLCID) + require.Equal(t, pkt.outgoingChanID, pktResponse.outgoingChanID) + require.Equal(t, pkt.outgoingHop, pktResponse.outgoingHop) + require.NotNil(t, pktResponse.linkFailure) + + var unknownNextPeer *lnwire.FailUnknownNextPeer + require.ErrorAs( + t, pktResponse.linkFailure.WireMessage(), + &unknownNextPeer, + "expected FailUnknownNextPeer for node-ID FailAdd", + ) + + case <-time.After(50 * time.Millisecond): + t.Fatal("timeout waiting for packet response") + } +} diff --git a/htlcswitch/packet.go b/htlcswitch/packet.go index ed5f82588..9af7e3432 100644 --- a/htlcswitch/packet.go +++ b/htlcswitch/packet.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/lightningnetwork/lnd/channeldb" + "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/graph/db/models" "github.com/lightningnetwork/lnd/htlcswitch/hop" "github.com/lightningnetwork/lnd/lnwire" @@ -18,9 +19,23 @@ type htlcPacket struct { incomingChanID lnwire.ShortChannelID // outgoingChanID is the ID of the channel that we have offered or will - // offer an outgoing HTLC on. + // offer an outgoing HTLC on. It is mutable and may remain zero + // (hop.Exit) until non-strict forwarding resolves a node-ID next hop to + // a concrete channel, or may differ from the requested SCID after + // non-strict load-balancing. A zero outgoingChanID alone does not imply + // an exit hop: if outgoingHop is a Right (node ID), the HTLC is a + // forward whose outgoing channel has not yet been selected. outgoingChanID lnwire.ShortChannelID + // outgoingHop carries the immutable next-hop instruction decoded from + // the onion payload, following the same encoding as + // hop.ForwardingInfo.NextHop. The three possible cases are: + // 1. Left(scid) where scid != Exit: a channel-addressed forward. + // 2. Right(pubkey): a node-addressed forward for a blinded route, + // resolved to an active link via non-strict forwarding. + // 3. Left(Exit): a final receive at the destination/receiver node. + outgoingHop fn.Either[lnwire.ShortChannelID, [33]byte] + // incomingHTLCID is the ID of the HTLC that we have received from the peer // on the incoming channel. incomingHTLCID uint64 @@ -104,11 +119,10 @@ type htlcPacket struct { // in the incoming update_add_htlc wire message. inWireCustomRecords lnwire.CustomRecords - // originalOutgoingChanID is used when sending back failure messages. - // It is only used for forwarded Adds on option_scid_alias channels. - // This is to avoid possible confusion if a payer uses the public SCID - // but receives a channel_update with the alias SCID. Instead, the - // payer should receive a channel_update with the public SCID. + // originalOutgoingChanID is used when sending back failure messages. It + // retains the original sender-facing requested SCID for forwarded Adds, + // including option_scid_alias channels. This prevents exposing the + // evaluated link's concrete SCID or alias in channel_update failures. originalOutgoingChanID lnwire.ShortChannelID // inboundFee is the fee schedule of the incoming channel. diff --git a/htlcswitch/switch.go b/htlcswitch/switch.go index a3aae809b..0cd796c29 100644 --- a/htlcswitch/switch.go +++ b/htlcswitch/switch.go @@ -2862,41 +2862,94 @@ func (s *Switch) handlePacketAdd(packet *htlcPacket, return s.failAddPacket(packet, failure) } - // Before we attempt to find a non-strict forwarding path for this - // htlc, check whether the htlc is being routed over the same incoming - // and outgoing channel. If our node does not allow forwards of this - // nature, we fail the htlc early. This check is in place to disallow - // inefficiently routed htlcs from locking up our balance. With - // channels where the option-scid-alias feature was negotiated, we also - // have to be sure that the IDs aren't the same since one or both could - // be an alias. - linkErr := s.checkCircularForward( - packet.incomingChanID, packet.outgoingChanID, - s.cfg.AllowCircularRoute, htlc.PaymentHash, - ) - if linkErr != nil { - return s.failAddPacket(packet, linkErr) - } + // Collect the links that could carry this HTLC to the next hop. + // Non-strict forwarding then load-balances across our channels to that + // peer. A short channel ID maps to a link and its peer, while a blinded + // node-ID next hop resolves the peer directly. A node-ID hop has no + // sender-specified channel, so outgoingChanID stays hop.Exit until + // selection. + var interfaceLinks []ChannelLink + if packet.outgoingHop.IsLeft() { + // Before we attempt to find a non-strict forwarding path for + // this htlc, check whether the htlc is being routed over the + // same incoming and outgoing channel. If our node does not + // allow forwards of this nature, we fail the htlc early. This + // check is in place to disallow inefficiently routed htlcs from + // locking up our balance. With channels where the + // option-scid-alias feature was negotiated, we also have to be + // sure that the IDs aren't the same since one or both could be + // an alias. + linkErr := s.checkCircularForward( + packet.incomingChanID, packet.outgoingChanID, + s.cfg.AllowCircularRoute, htlc.PaymentHash, + ) + if linkErr != nil { + return s.failAddPacket(packet, linkErr) + } - s.indexMtx.RLock() - targetLink, err := s.getLinkByMapping(packet) - if err != nil { + s.indexMtx.RLock() + targetLink, err := s.getLinkByMapping(packet) + if err != nil { + s.indexMtx.RUnlock() + + log.Debugf("unable to find link with "+ + "destination %v", packet.outgoingChanID) + + // If packet was forwarded from another channel link + // then we should notify this link that some error + // occurred. + linkError := NewLinkError( + &lnwire.FailUnknownNextPeer{}, + ) + + return s.failAddPacket(packet, linkError) + } + + // NOTE: for the SCID path, we fetch all links to the target + // peer. If parallel channels exist to the incoming peer, the + // candidate set may include the incoming channel even when a + // different SCID was requested. + targetPeer := targetLink.PeerPubKey() + interfaceLinks, _ = s.getLinks(targetPeer) + s.indexMtx.RUnlock() + } else { + // A blinded node-ID next hop identifies the peer directly, so + // resolve its links and let non-strict forwarding load-balance + // across our channels to that peer. + peerKey := packet.outgoingHop.UnwrapRightOr([33]byte{}) + + s.indexMtx.RLock() + interfaceLinks, _ = s.getLinks(peerKey) s.indexMtx.RUnlock() - log.Debugf("unable to find link with "+ - "destination %v", packet.outgoingChanID) + // Drop links that would form a disallowed circular route, so + // selection can't later land on the incoming channel. + var nonCircularLinks []ChannelLink + for _, link := range interfaceLinks { + linkErr := s.checkCircularForward( + packet.incomingChanID, link.ShortChanID(), + s.cfg.AllowCircularRoute, htlc.PaymentHash, + ) + if linkErr == nil { + nonCircularLinks = append( + nonCircularLinks, link, + ) + } + } + interfaceLinks = nonCircularLinks - // If packet was forwarded from another channel link than we - // should notify this link that some error occurred. - linkError := NewLinkError( - &lnwire.FailUnknownNextPeer{}, - ) + // Without a usable link to the peer (none exist, or all would + // be circular) we cannot forward. Fail as unknown next peer + // rather than attributing it to a specific channel. + if len(interfaceLinks) == 0 { + log.Debugf("no usable link to peer %x for blinded "+ + "next hop", peerKey) - return s.failAddPacket(packet, linkError) + return s.failAddPacket(packet, NewLinkError( + &lnwire.FailUnknownNextPeer{}, + )) + } } - targetPeerKey := targetLink.PeerPubKey() - interfaceLinks, _ := s.getLinks(targetPeerKey) - s.indexMtx.RUnlock() // We'll keep track of any HTLC failures during the link selection // process. This way we can return the error for precise link that the @@ -2943,6 +2996,18 @@ func (s *Switch) handlePacketAdd(packet *htlcPacket, // current policy, then we'll send back an error, but ensure we send // back the error sourced at the *target* link. if len(destinations) == 0 { + // A node-ID next hop has no requested outgoing channel. + // Returning a per-candidate failure could leak a private + // channel via its channel_update (a probing vector), so fail + // generically. Later errors don't include private data. Defense + // in depth: route blinding error handling hides it too via + // error conversion. + if packet.outgoingHop.IsRight() { + return s.failAddPacket(packet, NewLinkError( + &lnwire.FailUnknownNextPeer{}, + )) + } + // At this point, some or all of the links rejected the HTLC so // we couldn't forward it. So we'll try to look up the error // that came from the source. diff --git a/htlcswitch/switch_test.go b/htlcswitch/switch_test.go index 13563916e..3c8b9fea8 100644 --- a/htlcswitch/switch_test.go +++ b/htlcswitch/switch_test.go @@ -1991,6 +1991,139 @@ func TestCircularForwards(t *testing.T) { } } +// TestNodeIDNonStrictRouting ensures that when a blinded route identifies the +// next hop by node ID, non-strict forwarding deterministically selects a valid +// outgoing channel to that peer and never fails the HTLC by landing on the +// incoming channel. +func TestNodeIDNonStrictRouting(t *testing.T) { + t.Parallel() + + // bob is both the source of the incoming HTLC and the next hop + // identified by node ID, so we have two channels with bob: the channel + // the HTLC arrives on and a second, valid outgoing channel. + bobPeer, err := newMockServer( + t, "bob", testStartingHeight, nil, testDefaultDelta, + ) + require.NoError(t, err, "unable to create bob server") + + s, err := initSwitchWithTempDB(t, testStartingHeight) + require.NoError(t, err, "unable to init switch") + require.NoError(t, s.Start(), "unable to start switch") + defer func() { _ = s.Stop() }() + + // Disallow circular routes so that forwarding back over the incoming + // channel is rejected. + s.cfg.AllowCircularRoute = false + + incomingChanID, incomingScid := genID() + outgoingChanID, outgoingScid := genID() + + incomingLink := newMockChannelLink( + s, incomingChanID, incomingScid, emptyScid, bobPeer, + true, false, false, false, + ) + outgoingLink := newMockChannelLink( + s, outgoingChanID, outgoingScid, emptyScid, bobPeer, + true, false, false, false, + ) + require.NoError(t, s.AddLink(incomingLink), "unable to add incoming") + require.NoError(t, s.AddLink(outgoingLink), "unable to add outgoing") + + // Forward many HTLCs so that random selection would almost certainly + // land on the incoming channel, which will be sorted out by the switch. + const numHTLCs = 20 + for i := 0; i < numHTLCs; i++ { + var hash [sha256.Size]byte + hash[0] = byte(i) + + packet := &htlcPacket{ + incomingChanID: incomingLink.ShortChanID(), + incomingHTLCID: uint64(i), + outgoingHop: hop.NewNodeNextHop(bobPeer.PubKey()), + htlc: &lnwire.UpdateAddHTLC{ + PaymentHash: hash, + Amount: 1, + }, + obfuscator: NewMockObfuscator(), + } + + require.NoError(t, s.ForwardPackets(nil, packet)) + + select { + case p := <-outgoingLink.packets: + require.Nil(t, p.linkFailure, "unexpected link failure") + require.Equal( + t, outgoingLink.ShortChanID(), + p.outgoingChanID, + "forwarded over wrong channel", + ) + + case <-incomingLink.packets: + t.Fatal("HTLC forwarded over incoming (circular) " + + "channel") + + case <-time.After(time.Second): + t.Fatal("no timely reply from switch") + } + } +} + +// TestNodeIDNonStrictRoutingAllLinksCircular ensures that when a blinded route +// identifies the next hop by node ID, and the only channel we have with that +// peer is the incoming channel (forming a circular route), the switch fails the +// HTLC early upfront. +func TestNodeIDNonStrictRoutingAllLinksCircular(t *testing.T) { + t.Parallel() + + bobPeer, err := newMockServer( + t, "bob", testStartingHeight, nil, testDefaultDelta, + ) + require.NoError(t, err, "unable to create bob server") + + s, err := initSwitchWithTempDB(t, testStartingHeight) + require.NoError(t, err, "unable to init switch") + require.NoError(t, s.Start(), "unable to start switch") + defer func() { _ = s.Stop() }() + + // Disallow circular routes. + s.cfg.AllowCircularRoute = false + + incomingChanID, incomingScid := genID() + incomingLink := newMockChannelLink( + s, incomingChanID, incomingScid, emptyScid, bobPeer, + true, false, false, false, + ) + require.NoError(t, s.AddLink(incomingLink), "unable to add incoming") + + packet := &htlcPacket{ + incomingChanID: incomingLink.ShortChanID(), + incomingHTLCID: 1, + outgoingHop: hop.NewNodeNextHop(bobPeer.PubKey()), + htlc: &lnwire.UpdateAddHTLC{ + PaymentHash: [32]byte{1}, + Amount: 1, + }, + obfuscator: NewMockObfuscator(), + } + + err = s.ForwardPackets(nil, packet) + require.NoError(t, err, "unable to forward packets") + + select { + case p := <-incomingLink.packets: + require.NotNil(t, p.linkFailure, "expected early link failure") + wireErr := p.linkFailure.WireMessage() + var unknownNextPeer *lnwire.FailUnknownNextPeer + require.ErrorAs( + t, wireErr, &unknownNextPeer, + "expected FailUnknownNextPeer", + ) + + case <-time.After(time.Second): + t.Fatal("no timely reply from switch") + } +} + // TestCheckCircularForward tests the error returned by checkCircularForward // in cases where we allow and disallow same channel circular forwards. func TestCheckCircularForward(t *testing.T) { From 47a5449258ee152653a88510341e9a46e514164b Mon Sep 17 00:00:00 2001 From: bitromortac Date: Thu, 23 Jul 2026 16:22:54 +0000 Subject: [PATCH 108/134] htlcswitch: classify a node-ID forward as a forward event Now that the switch forwards blinded hops identified by node ID, a new problem surfaces in the HTLC event stream. A node-ID next hop has no outgoing short channel ID until non-strict forwarding selects one, so a forward that fails before selection still carries outgoingChanID == hop.Exit. getEventType keys the exit hop off that sentinel, so it misclassifies such a failed node-ID forward as a receive, mislabeling the event streamed via SubscribeHtlcEvents (a forwarding failure reported as a receive failure). Two paths reach getEventType before an SCID is selected: the fail packet built by failAddPacket and the resolution packet built by resolve, both of which dropped the decoded next hop. Carry outgoingHop into both, and classify a Right (node-ID) outgoingHop as a forward before the hop.Exit check. A node-ID next hop is always a forward, never the exit hop. (cherry picked from commit a4844ef52299bbe29259d9183164fe041871d18b) --- htlcswitch/htlcnotifier.go | 8 ++ htlcswitch/htlcnotifier_test.go | 139 +++++++++++++++++++++++++++++ htlcswitch/interceptable_switch.go | 1 + htlcswitch/switch.go | 1 + 4 files changed, 149 insertions(+) create mode 100644 htlcswitch/htlcnotifier_test.go diff --git a/htlcswitch/htlcnotifier.go b/htlcswitch/htlcnotifier.go index 4d4d33374..ac9bb3b06 100644 --- a/htlcswitch/htlcnotifier.go +++ b/htlcswitch/htlcnotifier.go @@ -466,6 +466,14 @@ func getEventType(pkt *htlcPacket) HtlcEventType { case pkt.incomingChanID == hop.Source: return HtlcEventTypeSend + // A node-ID (pubkey) next hop has no outgoing SCID until the switch + // selects one, so outgoingChanID may still be hop.Exit on an early + // failure. Such a hop is always a forward, never the exit, so classify + // it before the hop.Exit check to avoid reporting a forward as a + // receive. + case pkt.outgoingHop.IsRight(): + return HtlcEventTypeForward + case pkt.outgoingChanID == hop.Exit: return HtlcEventTypeReceive diff --git a/htlcswitch/htlcnotifier_test.go b/htlcswitch/htlcnotifier_test.go new file mode 100644 index 000000000..f1f07225e --- /dev/null +++ b/htlcswitch/htlcnotifier_test.go @@ -0,0 +1,139 @@ +package htlcswitch + +import ( + "testing" + "time" + + "github.com/lightningnetwork/lnd/htlcswitch/hop" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/stretchr/testify/require" +) + +// TestGetEventType asserts how getEventType classifies an htlcPacket as a send, +// receive or forward event. +func TestGetEventType(t *testing.T) { + t.Parallel() + + var nodeID [33]byte + nodeID[0] = 0x02 + + tests := []struct { + name string + pkt *htlcPacket + want HtlcEventType + }{ + { + name: "send", + pkt: &htlcPacket{incomingChanID: hop.Source}, + want: HtlcEventTypeSend, + }, + { + name: "receive at exit hop", + pkt: &htlcPacket{ + incomingChanID: lnwire.NewShortChanIDFromInt(1), + outgoingChanID: hop.Exit, + }, + want: HtlcEventTypeReceive, + }, + { + name: "forward by channel ID", + pkt: &htlcPacket{ + incomingChanID: lnwire.NewShortChanIDFromInt(1), + outgoingChanID: lnwire.NewShortChanIDFromInt(2), + }, + want: HtlcEventTypeForward, + }, + { + // A node-ID forward that failed before channel + // selection has outgoingChanID == hop.Exit but a Right + // (pubkey) next hop, so it must classify as a forward. + name: "forward by node ID before selection", + pkt: &htlcPacket{ + incomingChanID: lnwire.NewShortChanIDFromInt(1), + outgoingChanID: hop.Exit, + outgoingHop: hop.NewNodeNextHop(nodeID), + }, + want: HtlcEventTypeForward, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + require.Equal(t, tc.want, getEventType(tc.pkt)) + }) + } +} + +// TestGetEventTypeNodeIDReconstructedPackets asserts that node-ID forward +// packets reconstructed via failAddPacket and interceptedForward.resolve +// preserve outgoingHop and are correctly classified as HtlcEventTypeForward by +// getEventType. +func TestGetEventTypeNodeIDReconstructedPackets(t *testing.T) { + t.Parallel() + + var nodeID [33]byte + nodeID[0] = 0x02 + + inChanID := lnwire.NewShortChanIDFromInt(1) + chanID := lnwire.ChannelID{1} + + // Create a switch with a mailOrchestrator and mailbox. + s := &Switch{ + mailOrchestrator: newMailOrchestrator(&mailOrchConfig{}), + } + mailbox := s.mailOrchestrator.GetOrCreateMailBox(chanID, inChanID) + s.mailOrchestrator.BindLiveShortChanID(mailbox, chanID, inChanID) + + // 1. Verify failAddPacket reconstruction. + origPkt := &htlcPacket{ + incomingChanID: inChanID, + incomingHTLCID: 42, + outgoingChanID: hop.Exit, + outgoingHop: hop.NewNodeNextHop(nodeID), + obfuscator: NewMockObfuscator(), + } + linkErr := NewLinkError(&lnwire.FailUnknownNextPeer{}) + + err := s.failAddPacket(origPkt, linkErr) + require.Equal(t, linkErr, err) + + select { + case failPkt := <-mailbox.PacketOutBox(): + require.True(t, failPkt.outgoingHop.IsRight()) + require.Equal( + t, HtlcEventTypeForward, getEventType(failPkt), + "failAddPacket must classify as forward", + ) + case <-time.After(time.Second): + t.Fatal("failAddPacket did not deliver packet to mailbox") + } + + // 2. Verify interceptedForward.resolve reconstruction. + resolvePkt := &htlcPacket{ + incomingChanID: inChanID, + incomingHTLCID: 43, + outgoingChanID: hop.Exit, + outgoingHop: hop.NewNodeNextHop(nodeID), + obfuscator: NewMockObfuscator(), + } + fwd := &interceptedForward{ + htlcSwitch: s, + packet: resolvePkt, + } + + err = fwd.resolve(&lnwire.UpdateFailHTLC{}) + require.NoError(t, err) + + select { + case resPkt := <-mailbox.PacketOutBox(): + require.True(t, resPkt.outgoingHop.IsRight()) + require.Equal( + t, HtlcEventTypeForward, getEventType(resPkt), + "interceptedForward.resolve must classify as forward", + ) + case <-time.After(time.Second): + t.Fatal("resolve did not deliver packet to mailbox") + } +} diff --git a/htlcswitch/interceptable_switch.go b/htlcswitch/interceptable_switch.go index ac2d24ccc..5e379d0a4 100644 --- a/htlcswitch/interceptable_switch.go +++ b/htlcswitch/interceptable_switch.go @@ -891,6 +891,7 @@ func (f *interceptedForward) resolve(message lnwire.Message) error { incomingChanID: f.packet.incomingChanID, incomingHTLCID: f.packet.incomingHTLCID, outgoingChanID: f.packet.outgoingChanID, + outgoingHop: f.packet.outgoingHop, outgoingHTLCID: f.packet.outgoingHTLCID, isResolution: true, circuit: f.packet.circuit, diff --git a/htlcswitch/switch.go b/htlcswitch/switch.go index 0cd796c29..23a83a3b1 100644 --- a/htlcswitch/switch.go +++ b/htlcswitch/switch.go @@ -1250,6 +1250,7 @@ func (s *Switch) failAddPacket(packet *htlcPacket, failure *LinkError) error { incomingChanID: packet.incomingChanID, incomingHTLCID: packet.incomingHTLCID, outgoingChanID: packet.outgoingChanID, + outgoingHop: packet.outgoingHop, outgoingHTLCID: packet.outgoingHTLCID, incomingAmount: packet.incomingAmount, amount: packet.amount, From 02f0f61cab978419634b15c55fdcc06cc77b9e8e Mon Sep 17 00:00:00 2001 From: bitromortac Date: Fri, 24 Jul 2026 11:38:20 +0000 Subject: [PATCH 109/134] htlcswitch+lnrpc: report node-ID next hop to the off-chain HTLC interceptor When the switch forwards a blinded hop identified by node ID, it has not yet resolved a concrete outgoing channel at interception time. Expose the next hop to the interceptor: InterceptedForward.Packet() reports the packet's outgoing channel as-is (hop.Exit, since none is selected yet) and carries the requested pubkey in OutgoingNodeID. At the RPC boundary, forwardInterceptor.onIntercept maps a node-ID hop to the reserved NodeIDForwardSCID sentinel in outgoing_requested_chan_id and the pubkey in outgoing_requested_node_id, so a client switching on a zero channel ID to detect the exit hop does not misread the forward as a final receive. The sentinel is a wire-only concern, applied where the request is built rather than in the switch's internal InterceptedPacket, which stays truthful (OutgoingNodeID.IsSome() is the node-ID discriminator). (cherry picked from commit 32373b76c7b74aba29655aefce25c24ff0c2771a) --- htlcswitch/interceptable_switch.go | 1 + htlcswitch/interfaces.go | 18 +++++++++++++++++- lnrpc/routerrpc/forward_interceptor.go | 11 +++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/htlcswitch/interceptable_switch.go b/htlcswitch/interceptable_switch.go index 5e379d0a4..9ef686a65 100644 --- a/htlcswitch/interceptable_switch.go +++ b/htlcswitch/interceptable_switch.go @@ -705,6 +705,7 @@ func (f *interceptedForward) Packet() InterceptedPacket { HtlcID: f.packet.incomingHTLCID, }, OutgoingChanID: f.packet.outgoingChanID, + OutgoingNodeID: f.packet.outgoingHop.RightToSome(), Hash: f.htlc.PaymentHash, OutgoingExpiry: f.htlc.Expiry, OutgoingAmount: f.htlc.Amount, diff --git a/htlcswitch/interfaces.go b/htlcswitch/interfaces.go index 6a56b181e..f373aea8b 100644 --- a/htlcswitch/interfaces.go +++ b/htlcswitch/interfaces.go @@ -381,6 +381,14 @@ type InterceptableHtlcForwarder interface { // and resolve it later or let the switch execute its default behavior. type ForwardInterceptor func(InterceptedPacket) error +// NodeIDForwardSCID is the sentinel outgoing SCID reported to HTLC interceptor +// clients (at the RPC boundary) for a next hop identified by node ID (BOLT 4 +// next_node_id) rather than by channel. All bits are set, an out-of-range value +// that can never match a real or alias channel, so a client switching on a zero +// SCID to detect the exit hop does not read the forward as a final receive. The +// pubkey is in InterceptedPacket.OutgoingNodeID. +const NodeIDForwardSCID uint64 = ^uint64(0) + // InterceptedPacket contains the relevant information for the interceptor about // an HTLC. type InterceptedPacket struct { @@ -388,9 +396,17 @@ type InterceptedPacket struct { // packet. IncomingCircuit models.CircuitKey - // OutgoingChanID is the destination channel for this packet. + // OutgoingChanID is the destination channel for this packet. For a + // node-ID next hop with no concrete channel known yet it is hop.Exit + // and OutgoingNodeID holds the pubkey; the RPC layer maps that to the + // NodeIDForwardSCID sentinel before reporting it to a client. OutgoingChanID lnwire.ShortChannelID + // OutgoingNodeID is the next hop's compressed pubkey for a blinded + // route that identifies it by node ID (next_node_id). None in the + // common channel-ID case. + OutgoingNodeID fn.Option[[33]byte] + // Hash is the payment hash of the htlc. Hash lntypes.Hash diff --git a/lnrpc/routerrpc/forward_interceptor.go b/lnrpc/routerrpc/forward_interceptor.go index 61adf8f2b..a1a065ff5 100644 --- a/lnrpc/routerrpc/forward_interceptor.go +++ b/lnrpc/routerrpc/forward_interceptor.go @@ -100,6 +100,17 @@ func (r *forwardInterceptor) onIntercept( InWireCustomRecords: htlc.InWireCustomRecords, } + // A node-ID forward has no requested outgoing channel. Expose the + // requested pubkey and report the reserved NodeIDForwardSCID sentinel + // rather than a zero SCID. Older un-upgraded protobuf clients do not + // know about outgoing_requested_node_id and would otherwise interpret + // a zero SCID as an exit hop. + htlc.OutgoingNodeID.WhenSome(func(nodeID [33]byte) { + interceptionRequest.OutgoingRequestedNodeId = nodeID[:] + interceptionRequest.OutgoingRequestedChanId = + htlcswitch.NodeIDForwardSCID + }) + return r.stream.Send(interceptionRequest) } From 8989a1c851424b854585282d3f67d441ef63779e Mon Sep 17 00:00:00 2001 From: bitromortac Date: Thu, 23 Jul 2026 12:25:29 +0000 Subject: [PATCH 110/134] witness beacon: report node-ID next hop to the on-chain HTLC interceptor Extend the on-chain interceptor path in the witness beacon to expose a node-ID next hop, mirroring the off-chain path. A node-ID next hop has no outgoing channel of its own, so the beacon reports hop.Exit as the outgoing channel (via ForwardingInfo.NextHopChannel().UnwrapOr) and the requested next node's public key. The RPC boundary maps that to the NodeIDForwardSCID sentinel so the forward is not misread as a final receive. This is the requested next hop, not the channel eventually selected by non-strict forwarding, so the beacon deliberately does not resolve it against the circuit map. (cherry picked from commit 9c4b8bfec2e35d7fc2810cc3eb9b1162053f79bf) --- witness_beacon.go | 22 +++++++++++++++++----- witness_beacon_test.go | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/witness_beacon.go b/witness_beacon.go index 550a38adc..45799da33 100644 --- a/witness_beacon.go +++ b/witness_beacon.go @@ -106,14 +106,26 @@ func (p *preimageBeacon) SubscribeUpdates( }, } + // Report the forwarding next hop to the interceptor. A channel-ID next + // hop is reported directly; a node-ID next hop has no outgoing channel + // of its own, so outgoingChanID is hop.Exit and the requested node ID + // is exposed separately, exactly as the off-chain interceptor does. + // This is the requested next hop, not the channel that non-strict + // forwarding eventually selects, so we deliberately do not resolve it + // against the circuit map. The RPC boundary maps a node-ID hop to the + // NodeIDForwardSCID sentinel for the client. + // // Notify the htlc interceptor. There may be a client connected // and willing to supply a preimage. packet := &htlcswitch.InterceptedPacket{ - Hash: htlc.RHash, - IncomingExpiry: htlc.RefundTimeout, - IncomingAmount: htlc.Amt, - IncomingCircuit: inKey, - OutgoingChanID: payload.FwdInfo.NextHopChannel().UnwrapOr(hop.Exit), + Hash: htlc.RHash, + IncomingExpiry: htlc.RefundTimeout, + IncomingAmount: htlc.Amt, + IncomingCircuit: inKey, + OutgoingChanID: payload.FwdInfo.NextHopChannel().UnwrapOr( + hop.Exit, + ), + OutgoingNodeID: payload.FwdInfo.NextHopNode(), OutgoingExpiry: payload.FwdInfo.OutgoingCLTV, OutgoingAmount: payload.FwdInfo.AmountToForward, InOnionCustomRecords: payload.CustomRecords(), diff --git a/witness_beacon_test.go b/witness_beacon_test.go index 1edbada93..9c7cf5352 100644 --- a/witness_beacon_test.go +++ b/witness_beacon_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/lightningnetwork/lnd/channeldb" + "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/graph/db/models" "github.com/lightningnetwork/lnd/htlcswitch" "github.com/lightningnetwork/lnd/htlcswitch/hop" @@ -97,6 +98,47 @@ func TestWitnessBeaconInterceptErrorCancels(t *testing.T) { p.RUnlock() } +// TestWitnessBeaconInterceptNodeID asserts that for a node-ID next hop the +// on-chain interceptor reports the exit-hop SCID (hop.Exit) together with the +// requested next node's public key, matching the off-chain interceptor. The +// next hop is not resolved against the circuit map; the RPC boundary maps +// hop.Exit to the sentinel. +func TestWitnessBeaconInterceptNodeID(t *testing.T) { + var interceptedFwd htlcswitch.InterceptedForward + interceptor := func(fwd htlcswitch.InterceptedForward) error { + interceptedFwd = fwd + + return nil + } + + p := newPreimageBeacon( + &mockWitnessCache{}, interceptor, + func(models.CircuitKey) error { + return nil + }, + ) + + var nodeID [33]byte + nodeID[0] = 0x02 + + payload := &hop.Payload{ + FwdInfo: hop.ForwardingInfo{ + NextHop: hop.NewNodeNextHop(nodeID), + }, + } + + _, err := p.SubscribeUpdates( + lnwire.NewShortChanIDFromInt(1), + &channeldb.HTLC{RHash: lntypes.Hash{1}}, + payload, []byte{2}, + ) + require.NoError(t, err) + + packet := interceptedFwd.Packet() + require.Equal(t, hop.Exit, packet.OutgoingChanID) + require.Equal(t, fn.Some(nodeID), packet.OutgoingNodeID) +} + type mockWitnessCache struct { witnessCache } From 229be2a83f0dc64ed18855c1b59be9a16f5455c3 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Tue, 30 Jun 2026 15:38:32 +0200 Subject: [PATCH 111/134] itest: cover blinded route next_node_id forwarding Add integration tests for an lnd introduction node forwarding a blinded payment whose non-final hops identify the next hop by node ID (next_node_id) rather than a short channel ID, as produced by other implementations: - testBlindedRouteNextNodeID: the outgoing channel is public. - testBlindedRouteNextNodeIDPrivateChannel: the outgoing channel is private, so the node ID resolves to an SCID alias. - testBlindedRouteNextNodeIDRestart: the introduction node is restarted while the HTLC is in flight, exercising forwarding-package replay and re-decode of the node-ID blinded hop. (cherry picked from commit da6a40c01d4963df8a462672b423e059d9a859a8) --- itest/list_on_test.go | 12 + itest/lnd_route_blinding_test.go | 420 +++++++++++++++++++++++++++++++ 2 files changed, 432 insertions(+) diff --git a/itest/list_on_test.go b/itest/list_on_test.go index 02fd01218..6c9feacbe 100644 --- a/itest/list_on_test.go +++ b/itest/list_on_test.go @@ -591,6 +591,18 @@ var allTestCases = []*lntest.TestCase{ Name: "blinded payment htlc re-forward", TestFunc: testBlindedPaymentHTLCReForward, }, + { + Name: "blinded route next node id", + TestFunc: testBlindedRouteNextNodeID, + }, + { + Name: "blinded route next node id private channel", + TestFunc: testBlindedRouteNextNodeIDPrivateChannel, + }, + { + Name: "blinded route next node id restart", + TestFunc: testBlindedRouteNextNodeIDRestart, + }, { Name: "query blinded route", TestFunc: testQueryBlindedRoutes, diff --git a/itest/lnd_route_blinding_test.go b/itest/lnd_route_blinding_test.go index af2612d24..a387e907a 100644 --- a/itest/lnd_route_blinding_test.go +++ b/itest/lnd_route_blinding_test.go @@ -1,6 +1,7 @@ package itest import ( + "bytes" "context" "crypto/sha256" "encoding/hex" @@ -10,12 +11,16 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" + sphinx "github.com/lightningnetwork/lightning-onion" "github.com/lightningnetwork/lnd/chainreg" + "github.com/lightningnetwork/lnd/htlcswitch" "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnrpc/routerrpc" "github.com/lightningnetwork/lnd/lntest" "github.com/lightningnetwork/lnd/lntest/node" "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/record" + "github.com/lightningnetwork/lnd/tlv" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -383,6 +388,78 @@ func (b *blindedForwardTest) setupNetwork(ctx context.Context, } } +// setupNetworkPrivateMiddle sets up the same Alice -> Bob -> Carol -> Dave +// network as setupNetwork (with an interceptor on Carol), except that the +// Bob -> Carol channel is private. This is the channel the introduction node +// (Bob) must resolve to from Carol's node ID, exercising resolution to an SCID +// alias of an unadvertised channel. +func (b *blindedForwardTest) setupNetworkPrivateMiddle(ctx context.Context) { + carolArgs := []string{ + "--bitcoin.timelockdelta=24", + fmt.Sprintf("--bitcoin.defaultremotedelay=%v", toLocalCSV), + "--requireinterceptor", + } + daveArgs := []string{ + "--bitcoin.timelockdelta=24", + fmt.Sprintf("--bitcoin.defaultremotedelay=%v", toLocalCSV), + } + + alice := b.ht.NewNode("Alice", nil) + bob := b.ht.NewNode("Bob", nil) + carol := b.ht.NewNode("Carol", carolArgs) + dave := b.ht.NewNode("Dave", daveArgs) + b.alice, b.bob, b.carol, b.dave = alice, bob, carol, dave + + b.ht.EnsureConnected(alice, bob) + b.ht.EnsureConnected(bob, carol) + b.ht.EnsureConnected(carol, dave) + + // Fund every node that opens a channel. + const chanAmt = btcutil.Amount(100_000) + b.ht.FundCoins(btcutil.SatoshiPerBitcoin, alice) + b.ht.FundCoins(btcutil.SatoshiPerBitcoin, bob) + b.ht.FundCoins(btcutil.SatoshiPerBitcoin, carol) + + // Open Alice -> Bob and Carol -> Dave as public channels, but Bob -> + // Carol (the hop the introduction node must resolve by node ID) as a + // private channel, so it is only reachable via an SCID alias. + reqs := []*lntest.OpenChannelRequest{ + { + Local: alice, + Remote: bob, + Param: lntest.OpenChannelParams{Amt: chanAmt}, + }, + { + Local: bob, + Remote: carol, + Param: lntest.OpenChannelParams{ + Amt: chanAmt, + Private: true, + }, + }, + { + Local: carol, + Remote: dave, + Param: lntest.OpenChannelParams{Amt: chanAmt}, + }, + } + b.channels = b.ht.OpenMultiChannelsAsync(reqs) + + // Alice must know the public Alice -> Bob channel to build a route to + // the introduction node, and Bob and Carol must both know the private + // Bob -> Carol channel used for forwarding. + b.ht.AssertChannelInGraph(alice, b.channels[0]) + b.ht.AssertChannelInGraph(bob, b.channels[0]) + b.ht.AssertChannelInGraph(bob, b.channels[1]) + b.ht.AssertChannelInGraph(carol, b.channels[1]) + b.ht.AssertChannelInGraph(carol, b.channels[2]) + b.ht.AssertChannelInGraph(dave, b.channels[2]) + + var err error + b.carolInterceptor, err = b.carol.RPC.Router.HtlcInterceptor(ctx) + require.NoError(b.ht, err, "interceptor") +} + // buildBlindedPath returns a blinded route from Bob -> Carol -> Dave, with Bob // acting as the introduction point. func (b *blindedForwardTest) buildBlindedPath() *lnrpc.BlindedPaymentPath { @@ -1421,6 +1498,349 @@ func testBlindedPaymentHTLCReForward(ht *lntest.HarnessTest) { } } +// nextNodeIDRouteData builds the recipient data for a non-final blinded hop +// that identifies the next hop by its node ID (next_node_id) rather than a +// short channel ID. This is the form of recipient data that a non-lnd +// implementation may produce and that the forwarding node must resolve to one +// of its active channels. +func nextNodeIDRouteData(nextNode *btcec.PublicKey, + relayInfo record.PaymentRelayInfo, + constraints *record.PaymentConstraints) *record.BlindedRouteData { + + return &record.BlindedRouteData{ + NextNodeID: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType4](nextNode), + ), + RelayInfo: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType10](relayInfo), + ), + Constraints: tlv.SomeRecordT( + tlv.NewRecordT[tlv.TlvType12](*constraints), + ), + } +} + +// buildBlindedPathWithNextNodeID constructs a Bob -> Carol -> Dave blinded path +// in which the non-final hops (Bob and Carol) identify their next hop by node +// ID instead of a short channel ID. Bob is the introduction node. The returned +// path can be used to exercise an lnd forwarding node's ability to resolve a +// next_node_id to one of its active channels. +func (b *blindedForwardTest) buildBlindedPathWithNextNodeID( + paymentAmt int64) *lnrpc.BlindedPaymentPath { + + bobPub, err := btcec.ParsePubKey(b.bob.PubKey[:]) + require.NoError(b.ht, err) + + carolPub, err := btcec.ParsePubKey(b.carol.PubKey[:]) + require.NoError(b.ht, err) + + davePub, err := btcec.ParsePubKey(b.dave.PubKey[:]) + require.NoError(b.ht, err) + + // Use zero fees so that the forwarded amount remains constant along the + // path, keeping the route math trivial. + const ( + hopCltvDelta uint16 = 144 + finalCltvDelta uint32 = 24 + ) + + // Set a generous max CLTV constraint so that the incoming expiry at + // each hop never trips the payment constraints check. + info := b.alice.RPC.GetInfo() + constraints := &record.PaymentConstraints{ + MaxCltvExpiry: info.BlockHeight + 10_000, + HtlcMinimumMsat: 0, + } + relayInfo := record.PaymentRelayInfo{ + CltvExpiryDelta: hopCltvDelta, + FeeRate: 0, + BaseFee: 0, + } + + // Bob (the introduction node) forwards to Carol and Carol forwards to + // Dave, each identified purely by node ID. Dave is the final hop; its + // path ID is arbitrary because the payment is settled at Carol via the + // interceptor before it ever reaches Dave. + hopData := []struct { + pub *btcec.PublicKey + data *record.BlindedRouteData + }{ + { + pub: bobPub, + data: nextNodeIDRouteData( + carolPub, relayInfo, constraints, + ), + }, + { + pub: carolPub, + data: nextNodeIDRouteData( + davePub, relayInfo, constraints, + ), + }, + { + pub: davePub, + data: record.NewFinalHopBlindedRouteData( + constraints, bytes.Repeat([]byte{1}, 32), + ), + }, + } + + paymentPath := make([]*sphinx.HopInfo, len(hopData)) + for i, hop := range hopData { + plainText, err := record.EncodeBlindedRouteData(hop.data) + require.NoError(b.ht, err) + + paymentPath[i] = &sphinx.HopInfo{ + NodePub: hop.pub, + PlainText: plainText, + } + } + + // Encrypt the per-hop data into a blinded path using a fresh session + // key. + sessionKey, err := btcec.NewPrivateKey() + require.NoError(b.ht, err) + + blindedPathInfo, err := sphinx.BuildBlindedPath(sessionKey, paymentPath) + require.NoError(b.ht, err) + blindedPath := blindedPathInfo.Path + + // The introduction node is communicated in plaintext, so overwrite the + // first hop's blinded pub key with the real introduction point. + blindedPath.BlindedHops[0].BlindedNodePub = + blindedPath.IntroductionPoint + + blindedHops := make( + []*lnrpc.BlindedHop, len(blindedPath.BlindedHops), + ) + for i, hop := range blindedPath.BlindedHops { + blindedHops[i] = &lnrpc.BlindedHop{ + BlindedNode: hop.BlindedNodePub.SerializeCompressed(), + EncryptedData: hop.CipherText, + } + } + + return &lnrpc.BlindedPaymentPath{ + BlindedPath: &lnrpc.BlindedPath{ + IntroductionNode: b.bob.PubKey[:], + BlindingPoint: blindedPath.BlindingPoint. + SerializeCompressed(), + BlindedHops: blindedHops, + }, + BaseFeeMsat: 0, + TotalCltvDelta: 2*uint32(hopCltvDelta) + finalCltvDelta, + HtlcMinMsat: 0, + HtlcMaxMsat: uint64(paymentAmt) * 2, + } +} + +// testBlindedRouteNextNodeID tests that an lnd node acting as the introduction +// node of a blinded path can forward a payment when the recipient identifies +// the next hop by its node ID (next_node_id) rather than a short channel ID. +// The introduction node must resolve the node ID to one of its active channels +// with that peer. +func testBlindedRouteNextNodeID(ht *lntest.HarnessTest) { + ctx, testCase := newBlindedForwardTest(ht) + defer testCase.cleanup() + + // Set up the Alice -> Bob -> Carol -> Dave network with an interceptor + // on Carol. Bob is the introduction node whose node ID resolution we + // want to exercise, and Carol's interceptor lets us deterministically + // observe that Bob successfully resolved and forwarded the HTLC. + testCase.setupNetwork(ctx, true) + + testCase.runNextNodeIDForward(ctx, nil) +} + +// testBlindedRouteNextNodeIDPrivateChannel is like testBlindedRouteNextNodeID, +// but the Bob -> Carol channel that the introduction node must resolve by node +// ID is private. This exercises the introduction node's ability to resolve the +// next node's ID to an SCID alias of an unadvertised channel (option-scid-alias +// channels are not forwardable by their confirmed SCID). +func testBlindedRouteNextNodeIDPrivateChannel(ht *lntest.HarnessTest) { + ctx, testCase := newBlindedForwardTest(ht) + defer testCase.cleanup() + + // Set up Alice -> Bob -> Carol -> Dave where the Bob -> Carol channel + // is private, so Bob must resolve Carol's node ID to that channel's + // alias. + testCase.setupNetworkPrivateMiddle(ctx) + + testCase.runNextNodeIDForward(ctx, nil) +} + +// testBlindedRouteNextNodeIDRestart tests that a blinded payment forwarded by +// node ID survives a restart of the introduction node. The HTLC is held at the +// receiver's interceptor after the introduction node (Bob) has resolved the +// next node's ID and forwarded it. Bob is then restarted, forcing it to replay +// its forwarding package and re-decode the node-ID blinded hop, after which the +// in-flight HTLC must remain intact and the payment must still settle. +func testBlindedRouteNextNodeIDRestart(ht *lntest.HarnessTest) { + ctx, testCase := newBlindedForwardTest(ht) + defer testCase.cleanup() + + testCase.setupNetwork(ctx, true) + + // Open a second, parallel Bob -> Carol channel with zero fees, matching + // the zero-fee policy runNextNodeIDForward sets on channels[1]. The + // blinded path identifies the hop by Carol's node ID, so both Bob -> + // Carol channels are valid candidates and Bob's non-strict forwarding + // picks one at random. We use this to prove that replaying the + // forwarding package after a restart re-pins the same randomly selected + // channel and does not duplicate the HTLC onto the other one. + ht.FundCoins(btcutil.SatoshiPerBitcoin, testCase.bob) + parallel := ht.OpenChannel( + testCase.bob, testCase.carol, + lntest.OpenChannelParams{Amt: chanAmt}, + ) + testCase.bob.RPC.UpdateChannelPolicy(&lnrpc.PolicyUpdateRequest{ + Scope: &lnrpc.PolicyUpdateRequest_ChanPoint{ + ChanPoint: parallel, + }, + BaseFeeMsat: 0, + FeeRatePpm: 0, + TimeLockDelta: 80, + }) + + testCase.runNextNodeIDForward(ctx, func() { + hash := sha256.Sum256(testCase.preimage[:]) + + // Non-strict forwarding picked one of the two Bob -> Carol + // channels at random. Find which one currently carries the + // outgoing HTLC so we can assert it stays there across the + // restart. + chosen, other := testCase.channels[1], parallel + if channelHasHTLC(ht, testCase.bob, parallel, hash[:]) { + chosen, other = parallel, testCase.channels[1] + } + + // Restart the introduction node while the HTLC is held at + // Carol's interceptor. On startup Bob replays its forwarding + // package and must re-decode the node-ID blinded hop without + // disturbing the already forwarded HTLC. + ht.RestartNode(testCase.bob) + ht.EnsureConnected(testCase.alice, testCase.bob) + ht.EnsureConnected(testCase.bob, testCase.carol) + + // After replaying its forwarding package, the in-flight HTLC + // must still be on the originally selected channel and must not + // have been duplicated onto the other Bob -> Carol channel. Bob + // therefore holds exactly two active HTLCs: the incoming one + // from Alice and the single outgoing one to Carol. + ht.AssertOutgoingHTLCActive(testCase.bob, chosen, hash[:]) + ht.AssertHTLCNotActive(testCase.bob, other, hash[:]) + ht.AssertNumActiveHtlcs(testCase.bob, 2) + }) +} + +// channelHasHTLC reports whether the given channel currently has a pending +// HTLC locked in for the provided payment hash. +func channelHasHTLC(ht *lntest.HarnessTest, hn *node.HarnessNode, + cp *lnrpc.ChannelPoint, hash []byte) bool { + + channel := ht.GetChannelByChanPoint(hn, cp) + for _, htlc := range channel.PendingHtlcs { + if bytes.Equal(htlc.HashLock, hash) { + return true + } + } + + return false +} + +// runNextNodeIDForward drives a payment along a blinded path whose non-final +// hops identify the next hop by node ID, asserting that the lnd introduction +// node (Bob) resolves the node ID to one of its channels and forwards the HTLC +// to Carol, who settles it via her interceptor. If midFlight is non-nil it is +// invoked while the HTLC is held at Carol's interceptor, before it is settled, +// letting callers exercise behaviour such as restarting the introduction node. +func (b *blindedForwardTest) runNextNodeIDForward(ctx context.Context, + midFlight func()) { + + ht := b.ht + + // Since buildBlindedPathWithNextNodeID constructs a path with zero + // fees to keep routing math trivial, we must update Bob's outgoing + // channel policy to have zero fees so that forwarding is not rejected + // with FeeInsufficient. + bobUpdateReq := &lnrpc.PolicyUpdateRequest{ + Scope: &lnrpc.PolicyUpdateRequest_ChanPoint{ + ChanPoint: b.channels[1], + }, + BaseFeeMsat: 0, + FeeRatePpm: 0, + TimeLockDelta: 80, + } + b.bob.RPC.UpdateChannelPolicy(bobUpdateReq) + + const paymentAmt = 10_000_000 + blindedPath := b.buildBlindedPathWithNextNodeID(paymentAmt) + route := b.createRouteToBlinded(paymentAmt, blindedPath) + + hash := sha256.Sum256(b.preimage[:]) + sendReq := &routerrpc.SendToRouteRequest{ + PaymentHash: hash[:], + Route: route, + } + + // Dispatch the payment in the background since the HTLC will be held by + // Carol's interceptor until we resolve it. + done := make(chan struct{}) + go func() { + defer close(done) + + htlcAttempt, err := b.alice.RPC.Router.SendToRouteV2( + ctx, sendReq, + ) + require.NoError(ht, err) + require.Equal( + ht, lnrpc.HTLCAttempt_SUCCEEDED, htlcAttempt.Status, + ) + }() + + // Bob holding two active HTLCs (one incoming from Alice, one outgoing + // to Carol) demonstrates that Bob (the lnd introduction node) resolved + // Carol's node ID and forwarded the HTLC onwards. We assert on the + // count rather than a specific Bob -> Carol channel because non-strict + // forwarding may pick any of Bob's channels to Carol. + ht.AssertOutgoingHTLCActive(b.alice, b.channels[0], hash[:]) + ht.AssertNumActiveHtlcs(b.bob, 2) + + // Carol intercepts the forwarded HTLC, confirming that the introduction + // node's resolution and forwarding succeeded. Settle it with the + // preimage so that Alice's payment completes successfully. + interceptor := b.carolInterceptor + carolHTLC, err := interceptor.Recv() + require.NoError(ht, err) + + // Carol's own onward hop to Dave is also identified by node ID, so her + // intercept request must expose Dave's pubkey and flag the node-ID + // forward with the sentinel outgoing channel rather than a zero SCID. + require.Equal( + ht, htlcswitch.NodeIDForwardSCID, + carolHTLC.OutgoingRequestedChanId, + ) + require.Equal(ht, b.dave.PubKey[:], carolHTLC.OutgoingRequestedNodeId) + + // Run any caller-supplied step while the HTLC is held mid-flight. + if midFlight != nil { + midFlight() + } + + err = interceptor.Send(&routerrpc.ForwardHtlcInterceptResponse{ + IncomingCircuitKey: carolHTLC.IncomingCircuitKey, + Action: routerrpc.ResolveHoldForwardAction_SETTLE, + Preimage: b.preimage[:], + }) + require.NoError(ht, err) + + select { + case <-done: + case <-time.After(defaultTimeout): + require.Fail(ht, "timeout waiting for payment to complete") + } +} + // testPartiallySpecifiedBlindedPath tests lnd's ability to: // - Assert the error when attempting to create a blinded payment with an // invalid partially specified path. From a08de6de32ea9920b499525b0900b6c8ee308f09 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Wed, 29 Jul 2026 19:18:49 +0000 Subject: [PATCH 112/134] docs: update release notes Add the blinded node-ID forwarding changes to the v0.20.2 release notes. (cherry picked from commit f42b4298992a64d49db0b0ddbf774f68ead089fd) --- docs/release-notes/release-notes-0.20.2.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/release-notes/release-notes-0.20.2.md b/docs/release-notes/release-notes-0.20.2.md index f7463c95f..e426b35df 100644 --- a/docs/release-notes/release-notes-0.20.2.md +++ b/docs/release-notes/release-notes-0.20.2.md @@ -42,6 +42,10 @@ ## RPC Additions +* The [HTLC interceptor](https://github.com/lightningnetwork/lnd/pull/10942) now + exposes the next hop of a blinded route that identifies it by node ID + (`next_node_id`) rather than by channel. + ## lncli Additions # Improvements @@ -60,6 +64,15 @@ ## RPC Updates +* `ForwardHtlcInterceptRequest.outgoing_requested_chan_id` now holds a reserved + sentinel value (`18446744073709551615`, all bits set) when the + [HTLC interceptor](https://github.com/lightningnetwork/lnd/pull/10942) reports + a blinded forward that identifies the next hop by node ID. The sender of such + a forward requests no channel, so a zero value here would make a client that + detects the exit hop by a zero channel ID classify the forward as a final + receive. Clients that switch on this field must handle the sentinel and read + `outgoing_requested_node_id` for the next hop. + ## lncli Updates ## Breaking Changes @@ -71,6 +84,13 @@ # Technical and Architectural Updates ## BOLT Spec Updates +* [Fixed an issue](https://github.com/lightningnetwork/lnd/pull/10942) where an + lnd node acting as a relaying node (including the introduction node) in a + blinded path failed to forward the payment when the next hop was identified by + node ID (`next_node_id`) rather than a short channel ID. The next hop's public + key is now resolved to one of our channels with that peer using non-strict + forwarding. + ## Testing ## Database From f5ccc922e20f888a8cc7c821fd342eea067a0334 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Thu, 23 Jul 2026 16:09:24 -0700 Subject: [PATCH 113/134] lnwire: cap decoded short channel IDs In this commit, we cap each decompressed short channel ID set at 100,000 entries, matching the aggregate range reply budget. The old zlib reader bounded compressed input rather than decoded output, so the two working-set limits could drift apart. We retain compatibility with protocol-valid compressed replies, reject truncated or corrupt zlib streams, and close the reader on every exit. Boundary, compatibility, corruption, and property tests cover the decoder. (cherry picked from commit d162291941b2f119d47cc28f0cc098f72400b766) --- lnwire/query_short_chan_ids.go | 113 ++++++++------- lnwire/query_short_chan_ids_test.go | 208 ++++++++++++++++++++++++++++ 2 files changed, 271 insertions(+), 50 deletions(-) diff --git a/lnwire/query_short_chan_ids.go b/lnwire/query_short_chan_ids.go index 37a73ab7c..38f2680c5 100644 --- a/lnwire/query_short_chan_ids.go +++ b/lnwire/query_short_chan_ids.go @@ -3,6 +3,7 @@ package lnwire import ( "bytes" "compress/zlib" + "errors" "fmt" "io" "sort" @@ -12,10 +13,10 @@ import ( ) const ( - // maxZlibBufSize is the max number of bytes that we'll accept from a - // zlib decoding instance. We do this in order to limit the total - // amount of memory allocated during a decoding instance. - maxZlibBufSize = 67413630 + // maxDecodedShortChanIDs is the maximum number of short channel IDs + // accepted from a single message. The plain encoding is also bounded + // by the wire size, so its check is defense in depth. + maxDecodedShortChanIDs = 100_000 ) // ErrUnsortedSIDs is returned when decoding a QueryShortChannelID request whose @@ -164,6 +165,12 @@ func decodeShortChanIDs(r io.Reader) (QueryEncoding, []ShortChannelID, error) { // compute the number of bytes encoded based on the size of the // query body. numShortChanIDs := len(queryBody) / 8 + if numShortChanIDs > maxDecodedShortChanIDs { + return 0, nil, fmt.Errorf( + "too many short channel IDs: max=%v, got=%v", + maxDecodedShortChanIDs, numShortChanIDs, + ) + } if numShortChanIDs == 0 { return encodingType, nil, nil } @@ -210,61 +217,28 @@ func decodeShortChanIDs(r io.Reader) (QueryEncoding, []ShortChannelID, error) { return encodingType, nil, nil } - // Before we start to decode, we'll create a limit reader over - // the current reader. This will ensure that we can control how - // much memory we're allocating during the decoding process. - limitedDecompressor, err := zlib.NewReader(&io.LimitedReader{ - R: bytes.NewReader(queryBody), - N: maxZlibBufSize, - }) + decompressor, err := zlib.NewReader(bytes.NewReader(queryBody)) if err != nil { return 0, nil, fmt.Errorf("unable to create zlib "+ "reader: %w", err) } - var ( - shortChanIDs []ShortChannelID - lastChanID ShortChannelID - i int + shortChanIDs, decodeErr := decodeCompressedShortChanIDs( + decompressor, ) - for { - // We'll now attempt to read the next short channel ID - // encoded in the payload. - var cid ShortChannelID - err := ReadElements(limitedDecompressor, &cid) + closeErr := decompressor.Close() - switch { - // If we get an EOF error, then that either means we've - // read all that's contained in the buffer, or have hit - // our limit on the number of bytes we'll read. In - // either case, we'll return what we have so far. - case err == io.ErrUnexpectedEOF || err == io.EOF: - return encodingType, shortChanIDs, nil + switch { + case decodeErr != nil: + return 0, nil, decodeErr - // Otherwise, we hit some other sort of error, possibly - // an invalid payload, so we'll exit early with the - // error. - case err != nil: - return 0, nil, fmt.Errorf("unable to "+ - "deflate next short chan "+ - "ID: %v", err) - } + case closeErr != nil: + return 0, nil, fmt.Errorf( + "unable to close zlib reader: %w", closeErr, + ) - // We successfully read the next ID, so we'll collect - // that in the set of final ID's to return. - shortChanIDs = append(shortChanIDs, cid) - - // Finally, we'll ensure that this short chan ID is - // greater than the last one. This is a requirement - // within the encoding, and if violated can aide us in - // detecting malicious payloads. This can only be true - // starting at the second chanID. - if i > 0 && cid.ToUint64() <= lastChanID.ToUint64() { - return 0, nil, ErrUnsortedSIDs{lastChanID, cid} - } - - lastChanID = cid - i++ + default: + return encodingType, shortChanIDs, nil } default: @@ -275,6 +249,45 @@ func decodeShortChanIDs(r io.Reader) (QueryEncoding, []ShortChannelID, error) { } } +// decodeCompressedShortChanIDs decodes and validates the decompressed short +// channel ID stream. +func decodeCompressedShortChanIDs(r io.Reader) ([]ShortChannelID, error) { + var ( + shortChanIDs []ShortChannelID + lastChanID ShortChannelID + ) + + for { + var cid ShortChannelID + err := ReadElements(r, &cid) + + switch { + // Only a clean EOF terminates the stream. A partial final ID + // returns io.ErrUnexpectedEOF and remains an error. + case errors.Is(err, io.EOF): + return shortChanIDs, nil + + case err != nil: + return nil, fmt.Errorf("unable to deflate next short "+ + "chan ID: %w", err) + } + + if len(shortChanIDs) == maxDecodedShortChanIDs { + return nil, fmt.Errorf("too many short channel IDs: "+ + "max=%v", maxDecodedShortChanIDs) + } + + if len(shortChanIDs) > 0 && + cid.ToUint64() <= lastChanID.ToUint64() { + + return nil, ErrUnsortedSIDs{lastChanID, cid} + } + + shortChanIDs = append(shortChanIDs, cid) + lastChanID = cid + } +} + // Encode serializes the target QueryShortChanIDs into the passed io.Writer // observing the protocol version specified. // diff --git a/lnwire/query_short_chan_ids_test.go b/lnwire/query_short_chan_ids_test.go index 996c9f744..c45235d79 100644 --- a/lnwire/query_short_chan_ids_test.go +++ b/lnwire/query_short_chan_ids_test.go @@ -3,6 +3,9 @@ package lnwire import ( "bytes" "testing" + + "github.com/stretchr/testify/require" + "pgregory.net/rapid" ) type unsortedSidTest struct { @@ -118,3 +121,208 @@ func TestQueryShortChanIDsZero(t *testing.T) { }) } } + +// TestQueryShortChanIDsRoundTrip uses property-based testing to ensure both +// supported encodings preserve sorted short channel ID sets. +func TestQueryShortChanIDsRoundTrip(t *testing.T) { + t.Parallel() + + rapid.Check(t, func(t *rapid.T) { + encoding := rapid.SampledFrom([]QueryEncoding{ + EncodingSortedPlain, + EncodingSortedZlib, + }).Draw(t, "encoding") + + numSCIDs := rapid.IntRange(0, 512).Draw(t, "num-scids") + var scids []ShortChannelID + if numSCIDs > 0 { + scids = make([]ShortChannelID, numSCIDs) + } + + offset := rapid.IntRange(0, 1_000_000).Draw(t, "offset") + step := rapid.IntRange(1, 1_000_000).Draw(t, "step") + for i := range scids { + scid := uint64(offset + i*step) + scids[i] = NewShortChanIDFromInt(scid) + } + + var b bytes.Buffer + require.NoError(t, encodeShortChanIDs( + &b, encoding, scids, + )) + + decodedEncoding, decoded, err := decodeShortChanIDs( + bytes.NewReader(b.Bytes()), + ) + require.NoError(t, err) + require.Equal(t, encoding, decodedEncoding) + require.Equal(t, scids, decoded) + }) +} + +// TestQueryShortChanIDsDecodeLimit ensures that a decompressed short channel +// ID stream cannot exceed its resource limit. +func TestQueryShortChanIDsDecodeLimit(t *testing.T) { + t.Parallel() + + var stream bytes.Buffer + for i := 0; i <= maxDecodedShortChanIDs; i++ { + require.NoError(t, WriteElements( + &stream, NewShortChanIDFromInt(uint64(i)), + )) + } + + decoded, err := decodeCompressedShortChanIDs(bytes.NewReader( + stream.Bytes()[:maxDecodedShortChanIDs*8], + )) + require.NoError(t, err) + require.Len(t, decoded, maxDecodedShortChanIDs) + + _, err = decodeCompressedShortChanIDs( + bytes.NewReader(stream.Bytes()), + ) + require.ErrorContains(t, err, "too many short channel IDs") +} + +// TestQueryShortChanIDsZlibCompatibility ensures that a protocol-valid +// compressed reply can contain far more short channel IDs than a plain reply. +// The plain encoding is bounded by the wire size at maxPlainReplySCIDs, so it +// is the compressed encoding that determines how much headroom a single reply +// actually has. +func TestQueryShortChanIDsZlibCompatibility(t *testing.T) { + t.Parallel() + + const ( + // maxWireMsgSize is the largest a message may be on the wire, + // including its type prefix. + maxWireMsgSize = MaxMsgBody + MessageTypeSize + + // maxPlainReplySCIDs is the number of SCIDs that saturate a + // ReplyChannelRange under the plain encoding. The message + // carries 41 bytes of fixed fields, and the SCID blob adds a + // 2-byte length prefix plus a 1-byte encoding type, leaving + // (65533 - 44) / 8 SCIDs. + maxPlainReplySCIDs = 8186 + + // maxZlibReplySCIDs is the number of consecutive SCIDs that + // saturate the same message under the zlib encoding. Runs of + // consecutive SCIDs are the best case for the compressor, so + // this is an upper bound rather than a figure real peers hit. + maxZlibReplySCIDs = 30_794 + ) + + // A reply full of consecutive SCIDs is what we'll size both encodings + // against. + newReply := func(enc QueryEncoding, n int) *ReplyChannelRange { + scids := make([]ShortChannelID, n) + for i := range scids { + scids[i] = NewShortChanIDFromInt(uint64(i)) + } + + return &ReplyChannelRange{ + Complete: 1, + EncodingType: enc, + ShortChanIDs: scids, + ExtraData: make([]byte, 0), + } + } + + // The plain encoding tops out at maxPlainReplySCIDs: that many SCIDs + // fit, and one more overflows the message. + plain := newReply(EncodingSortedPlain, maxPlainReplySCIDs) + size, err := plain.SerializedSize() + require.NoError(t, err) + require.LessOrEqual(t, size, uint32(maxWireMsgSize)) + + plain = newReply(EncodingSortedPlain, maxPlainReplySCIDs+1) + size, err = plain.SerializedSize() + require.NoError(t, err) + require.Greater(t, size, uint32(maxWireMsgSize)) + + // The zlib encoding fits far more SCIDs into the very same message, + // which is the compatibility property we care about: a compressed + // reply can carry a much larger slice of the graph than a plain one. + zlib := newReply(EncodingSortedZlib, maxZlibReplySCIDs) + size, err = zlib.SerializedSize() + require.NoError(t, err) + require.LessOrEqual(t, size, uint32(maxWireMsgSize)) + require.Greater(t, maxZlibReplySCIDs, maxPlainReplySCIDs) + + // One more SCID pushes the compressed reply over the wire limit, so + // maxZlibReplySCIDs really is the ceiling. + over := newReply(EncodingSortedZlib, maxZlibReplySCIDs+1) + size, err = over.SerializedSize() + require.NoError(t, err) + require.Greater(t, size, uint32(maxWireMsgSize)) + + // Finally, the saturated compressed reply must still round trip + // cleanly through the decoder. + var b bytes.Buffer + require.NoError(t, encodeShortChanIDs( + &b, EncodingSortedZlib, zlib.ShortChanIDs, + )) + + encoding, decoded, err := decodeShortChanIDs( + bytes.NewReader(b.Bytes()), + ) + require.NoError(t, err) + require.Equal(t, EncodingSortedZlib, encoding) + require.Equal(t, zlib.ShortChanIDs, decoded) +} + +// TestQueryShortChanIDsRejectsCorruptZlib ensures that truncated or corrupt +// compressed streams are not accepted as valid partial replies. +func TestQueryShortChanIDsRejectsCorruptZlib(t *testing.T) { + t.Parallel() + + scids := []ShortChannelID{ + NewShortChanIDFromInt(1), + NewShortChanIDFromInt(2), + NewShortChanIDFromInt(3), + } + + var encoded bytes.Buffer + require.NoError(t, encodeShortChanIDs( + &encoded, EncodingSortedZlib, scids, + )) + + body := encoded.Bytes()[2:] + corruptChecksum := append([]byte(nil), body...) + corruptChecksum[len(corruptChecksum)-1] ^= 1 + + tests := []struct { + name string + body []byte + }{ + { + name: "truncated header", + body: body[:2], + }, + { + name: "truncated checksum", + body: body[:len(body)-1], + }, + { + name: "corrupt checksum", + body: corruptChecksum, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + var message bytes.Buffer + require.NoError(t, WriteElements( + &message, uint16(len(test.body)), + )) + _, err := message.Write(test.body) + require.NoError(t, err) + + _, _, err = decodeShortChanIDs( + bytes.NewReader(message.Bytes()), + ) + require.Error(t, err) + }) + } +} From e18a3428c5197555b345b8a209e748d92097dbbc Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Thu, 23 Jul 2026 16:09:54 -0700 Subject: [PATCH 114/134] discovery: bound channel range reply buffering In this commit, we cap each QueryChannelRange response at 100,000 SCIDs across all streamed replies. The existing reply-count limit did not track the aggregate decoded working set, so memory use varied with the encoding and composition of the reply stream. We count raw SCIDs before timestamp filtering, charge replies using the received encoding type, and release all accumulated range state on any error. This bounds both memory and CPU work while still leaving headroom above the current graph. (cherry picked from commit ceff94fadd25e825d1067c5f785c83ac8e651542) --- discovery/syncer.go | 103 +++++++-- discovery/syncer_test.go | 236 ++++++++++++++++++++- docs/release-notes/release-notes-0.20.2.md | 11 + 3 files changed, 335 insertions(+), 15 deletions(-) diff --git a/discovery/syncer.go b/discovery/syncer.go index ce970eeef..d3a7694cb 100644 --- a/discovery/syncer.go +++ b/discovery/syncer.go @@ -7,6 +7,7 @@ import ( "iter" "math" "math/rand" + "slices" "sort" "sync" "sync/atomic" @@ -169,6 +170,10 @@ const ( // the maximum number of replies allowed for zlib encoded replies. maxQueryChanRangeRepliesZlibFactor = 4 + // maxChanRangeReplySCIDs is the maximum number of short channel IDs + // we'll process for a single QueryChannelRange request. + maxChanRangeReplySCIDs = 100_000 + // chanRangeQueryBuffer is the number of blocks back that we'll go when // asking the remote peer for their any channels they know of beyond // our highest known channel ID. @@ -378,6 +383,10 @@ type GossipSyncer struct { // within the waitingQueryChanReply state. numChanRangeRepliesRcvd uint32 + // numChanRangeReplySCIDsRcvd tracks the total number of short channel + // IDs received as part of a QueryChannelRange response. + numChanRangeReplySCIDsRcvd uint32 + // newChansToQuery is used to pass the set of channels we should query // for from the waitingQueryChanReply state to the queryNewChannels // state. @@ -916,9 +925,41 @@ func isLegacyReplyChannelRange(query *lnwire.QueryChannelRange, // processChanRangeReply is called each time the GossipSyncer receives a new // reply to the initial range query to discover new channels that it didn't // previously know of. -func (g *GossipSyncer) processChanRangeReply(_ context.Context, +func (g *GossipSyncer) processChanRangeReply(ctx context.Context, msg *lnwire.ReplyChannelRange) error { + // Any error here terminates the range sync, so we release whatever we + // accumulated to stop the peer from pinning it by deliberately forcing + // an error. Our caller exits the state machine on any error we return, + // and nothing prunes a syncer until its peer disconnects, so otherwise + // the buffer stays reachable from a syncer that will never run again. + err := g.bufferChanRangeReply(ctx, msg) + if err != nil { + g.resetChanRangeReplyState() + } + + return err +} + +// bufferChanRangeReply validates a single ReplyChannelRange against the query +// that prompted it, buffers the channels it announces, and advances the +// syncer's state once the reply stream is complete. +func (g *GossipSyncer) bufferChanRangeReply(_ context.Context, + msg *lnwire.ReplyChannelRange) error { + + // A reply only means anything in the context of the query that + // prompted it, and every check below reads that query. Today this is + // unreachable, as we only accept a reply in waitingQueryRangeReply and + // we always set the query before entering that state. It is worth + // guarding anyway: an error leaves the syncer sitting in + // waitingQueryRangeReply with the query cleared, so any future change + // that recovers the handler instead of tearing it down would turn this + // into a remote panic. + if g.curQueryRangeMsg == nil { + return fmt.Errorf("received channel range reply without an " + + "active query") + } + // isStale returns whether the timestamp is too far into the past. isStale := func(timestamp time.Time) bool { return time.Since(timestamp) > graph.DefaultChannelPruneExpiry @@ -971,8 +1012,44 @@ func (g *GossipSyncer) processChanRangeReply(_ context.Context, } } + // Charge the reply budget using the encoding that was actually + // received. The configured encoding is a local preference and does + // not describe the responder's message. + var replyCount uint32 + switch msg.EncodingType { + case lnwire.EncodingSortedPlain: + replyCount = 1 + + case lnwire.EncodingSortedZlib: + replyCount = maxQueryChanRangeRepliesZlibFactor + + default: + return fmt.Errorf( + "unhandled encoding type %v", msg.EncodingType, + ) + } + + numReplySCIDs := uint32(len(msg.ShortChanIDs)) + if g.numChanRangeReplySCIDsRcvd > maxChanRangeReplySCIDs || + numReplySCIDs > maxChanRangeReplySCIDs- + g.numChanRangeReplySCIDsRcvd { + + return fmt.Errorf("channel range reply exceeds maximum "+ + "number of short channel IDs: max=%v", + maxChanRangeReplySCIDs) + } + + g.numChanRangeRepliesRcvd += replyCount + g.numChanRangeReplySCIDsRcvd += numReplySCIDs g.prevReplyChannelRange = msg + // Reserve room for this reply in one shot instead of letting append + // grow the buffer an element at a time. Over a full reply stream this + // cuts the number of reallocations by about 3x. + g.bufferedChanRangeReplies = slices.Grow( + g.bufferedChanRangeReplies, int(numReplySCIDs), + ) + for i, scid := range msg.ShortChanIDs { info := graphdb.NewChannelUpdateInfo( scid, time.Time{}, time.Time{}, @@ -1017,15 +1094,6 @@ func (g *GossipSyncer) processChanRangeReply(_ context.Context, ) } - switch g.cfg.encodingType { - case lnwire.EncodingSortedPlain: - g.numChanRangeRepliesRcvd++ - case lnwire.EncodingSortedZlib: - g.numChanRangeRepliesRcvd += maxQueryChanRangeRepliesZlibFactor - default: - return fmt.Errorf("unhandled encoding type %v", g.cfg.encodingType) - } - log.Infof("GossipSyncer(%x): buffering chan range reply of size=%v", g.cfg.peerPub[:], len(msg.ShortChanIDs)) @@ -1072,10 +1140,7 @@ func (g *GossipSyncer) processChanRangeReply(_ context.Context, // As we've received the entirety of the reply, we no longer need to // hold on to the set of buffered replies or the original query that // prompted the replies, so we'll let that be garbage collected now. - g.curQueryRangeMsg = nil - g.prevReplyChannelRange = nil - g.bufferedChanRangeReplies = nil - g.numChanRangeRepliesRcvd = 0 + g.resetChanRangeReplyState() // If there aren't any channels that we don't know of, then we can // switch straight to our terminal state. @@ -1103,6 +1168,16 @@ func (g *GossipSyncer) processChanRangeReply(_ context.Context, return nil } +// resetChanRangeReplyState releases all state accumulated while processing a +// ReplyChannelRange stream. +func (g *GossipSyncer) resetChanRangeReplyState() { + g.curQueryRangeMsg = nil + g.prevReplyChannelRange = nil + g.bufferedChanRangeReplies = nil + g.numChanRangeRepliesRcvd = 0 + g.numChanRangeReplySCIDsRcvd = 0 +} + // genChanRangeQuery generates the initial message we'll send to the remote // party when we're kicking off the channel graph synchronization upon // connection. The historicalQuery boolean can be used to generate a query from diff --git a/discovery/syncer_test.go b/discovery/syncer_test.go index 2313d1c1d..39ba2cd33 100644 --- a/discovery/syncer_test.go +++ b/discovery/syncer_test.go @@ -2515,6 +2515,183 @@ func TestGossipSyncerMaxChannelRangeReplies(t *testing.T) { }, nil)) } +// TestGossipSyncerMaxChannelRangeSCIDs ensures that a gossip syncer rejects a +// range response once the aggregate number of short channel IDs exceeds its +// resource limit. +func TestGossipSyncerMaxChannelRangeSCIDs(t *testing.T) { + t.Parallel() + ctx := t.Context() + + _, syncer, _ := newTestSyncer( + lnwire.ShortChannelID{BlockHeight: latestKnownHeight}, + defaultEncoding, defaultChunkSize, + ) + + query, err := syncer.genChanRangeQuery(ctx, true) + require.NoError(t, err) + + scids := make([]lnwire.ShortChannelID, defaultChunkSize) + for i := range scids { + scids[i] = lnwire.NewShortChanIDFromInt(uint64(i)) + } + + reply := &lnwire.ReplyChannelRange{ + ChainHash: query.ChainHash, + FirstBlockHeight: query.FirstBlockHeight, + NumBlocks: query.NumBlocks, + EncodingType: lnwire.EncodingSortedPlain, + ShortChanIDs: scids, + } + + numFullReplies := maxChanRangeReplySCIDs / len(scids) + for i := 0; i < numFullReplies; i++ { + require.NoError(t, syncer.processChanRangeReply(ctx, reply)) + } + + require.Len( + t, syncer.bufferedChanRangeReplies, + numFullReplies*len(scids), + ) + + numRemaining := maxChanRangeReplySCIDs - + numFullReplies*len(scids) + reply.ShortChanIDs = scids[:numRemaining] + require.NoError(t, syncer.processChanRangeReply(ctx, reply)) + require.Len( + t, syncer.bufferedChanRangeReplies, + maxChanRangeReplySCIDs, + ) + + reply.ShortChanIDs = []lnwire.ShortChannelID{ + lnwire.NewShortChanIDFromInt(uint64(len(scids))), + } + err = syncer.processChanRangeReply(ctx, reply) + require.ErrorContains( + t, err, "exceeds maximum number of short channel IDs", + ) + require.Empty(t, syncer.bufferedChanRangeReplies) + require.Zero(t, syncer.numChanRangeReplySCIDsRcvd) + require.Nil(t, syncer.curQueryRangeMsg) +} + +// TestGossipSyncerChanRangeReplyNoQuery ensures that a range reply which +// arrives without an active query is rejected rather than dereferencing the +// nil query. +func TestGossipSyncerChanRangeReplyNoQuery(t *testing.T) { + t.Parallel() + ctx := t.Context() + + _, syncer, _ := newTestSyncer( + lnwire.ShortChannelID{BlockHeight: latestKnownHeight}, + defaultEncoding, defaultChunkSize, + ) + + // Note that we deliberately skip genChanRangeQuery here, so + // curQueryRangeMsg is still nil. + require.Nil(t, syncer.curQueryRangeMsg) + + err := syncer.processChanRangeReply(ctx, &lnwire.ReplyChannelRange{ + FirstBlockHeight: 0, + NumBlocks: 100, + EncodingType: lnwire.EncodingSortedPlain, + ShortChanIDs: []lnwire.ShortChannelID{ + lnwire.NewShortChanIDFromInt(1), + }, + }) + require.ErrorContains(t, err, "without an active query") +} + +// TestGossipSyncerCountsReceivedEncoding ensures that compressed range +// replies consume the larger reply budget even when the local syncer uses +// plain encoding. +func TestGossipSyncerCountsReceivedEncoding(t *testing.T) { + t.Parallel() + ctx := t.Context() + + _, syncer, _ := newTestSyncer( + lnwire.ShortChannelID{BlockHeight: latestKnownHeight}, + defaultEncoding, defaultChunkSize, + ) + + query, err := syncer.genChanRangeQuery(ctx, true) + require.NoError(t, err) + + reply := &lnwire.ReplyChannelRange{ + ChainHash: query.ChainHash, + FirstBlockHeight: query.FirstBlockHeight, + NumBlocks: query.NumBlocks, + EncodingType: lnwire.EncodingSortedZlib, + } + require.NoError(t, syncer.processChanRangeReply(ctx, reply)) + require.Equal( + t, uint32(maxQueryChanRangeRepliesZlibFactor), + syncer.numChanRangeRepliesRcvd, + ) +} + +// deliverOverBudgetRangeReply waits for the syncer to send its initial range +// query, then answers it with a single reply that overruns the aggregate SCID +// budget. Sending the query is what populates curQueryRangeMsg and moves the +// syncer into waitingQueryRangeReply, both of which ProcessQueryMsg requires. +func deliverOverBudgetRangeReply(t *testing.T, syncer *GossipSyncer, + msgChan chan []lnwire.Message) { + + t.Helper() + + var query *lnwire.QueryChannelRange + select { + case msgs := <-msgChan: + require.Len(t, msgs, 1) + + q, ok := msgs[0].(*lnwire.QueryChannelRange) + require.True(t, ok) + query = q + + case <-time.After(time.Second): + t.Fatal("expected query channel range request msg") + } + + scids := make([]lnwire.ShortChannelID, maxChanRangeReplySCIDs+1) + for i := range scids { + scids[i] = lnwire.NewShortChanIDFromInt(uint64(i)) + } + + // Complete is set so that, absent the budget check, this reply would be + // taken as the final one and carry on to the completion path. That is + // what lets assertRangeSyncAborted tell the two apart. + reply := &lnwire.ReplyChannelRange{ + ChainHash: query.ChainHash, + FirstBlockHeight: query.FirstBlockHeight, + NumBlocks: query.NumBlocks, + Complete: 1, + EncodingType: lnwire.EncodingSortedPlain, + ShortChanIDs: scids, + } + require.NoError(t, syncer.ProcessQueryMsg(reply, nil)) +} + +// assertRangeSyncAborted asserts that the syncer bailed out of its range sync +// rather than treating the reply stream as complete. Reaching the completion +// path would filter the buffered SCIDs against our local graph, so the absence +// of that request is what tells us the sync was torn down instead. +// +// NOTE: we cannot instead wait on the syncer's wait group, as ContextGuard +// holds a reference on it until the syncer is signalled to quit. +func assertRangeSyncAborted(t *testing.T, syncer *GossipSyncer) { + t.Helper() + + series, ok := syncer.cfg.channelSeries.(*mockChannelGraphTimeSeries) + require.True(t, ok) + + select { + case <-series.filterReq: + t.Fatal("syncer treated an over-budget reply stream as a " + + "completed response") + + default: + } +} + // TestGossipSyncerStateHandlerErrors tests that errors in state handlers cause // the channelGraphSyncer goroutine to exit cleanly without endless retry loops. // This is a table-driven test covering various error types and states. @@ -2527,6 +2704,16 @@ func TestGossipSyncerStateHandlerErrors(t *testing.T) { setupState func(*GossipSyncer) chunkSize int32 injectedErr error + + // deliverMsg, if set, is run after the syncer has been started + // and is used to drive the syncer into an error through the + // public message path rather than through sendMsg injection. + deliverMsg func(*testing.T, *GossipSyncer, + chan []lnwire.Message) + + // assertOutcome, if set, asserts the terminal state the syncer + // is left in once its goroutine has stopped. + assertOutcome func(*testing.T, *GossipSyncer) }{ { name: "context cancel during syncingChans", @@ -2567,6 +2754,41 @@ func TestGossipSyncerStateHandlerErrors(t *testing.T) { } }, }, + { + // Unlike the cases above, this one drives the error in + // through ProcessQueryMsg so that we exercise the + // syncer's lifecycle rather than calling + // processChanRangeReply directly. The syncer starts in + // syncingChans and moves itself into + // waitingQueryRangeReply once it has sent its query. + name: "SCID budget exceeded while waiting", + state: syncingChans, + chunkSize: defaultChunkSize, + injectedErr: nil, + setupState: func(s *GossipSyncer) {}, + deliverMsg: deliverOverBudgetRangeReply, + assertOutcome: func(t *testing.T, s *GossipSyncer) { + // The budget check must abort the sync rather + // than let the partial stream be taken as a + // completed response. + // + // NOTE: the release of the buffered reply + // state is asserted by + // TestGossipSyncerMaxChannelRangeSCIDs, which + // can read those fields directly without + // racing the syncer's own goroutine. + assertRangeSyncAborted(t, s) + + // NOTE: the syncer is left in + // waitingQueryRangeReply with no live handler. + // That matches how every other terminal error + // in this state machine behaves today. + require.Equal( + t, waitingQueryRangeReply, + s.syncState(), + ) + }, + }, } for _, tt := range tests { @@ -2576,7 +2798,7 @@ func TestGossipSyncerStateHandlerErrors(t *testing.T) { // Create syncer with error injection capability. hID := lnwire.NewShortChanIDFromInt(10) - syncer, errInj, _ := newErrorInjectingSyncer( + syncer, errInj, msgChan := newErrorInjectingSyncer( hID, tt.chunkSize, ) @@ -2592,6 +2814,12 @@ func TestGossipSyncerStateHandlerErrors(t *testing.T) { // goroutine. syncer.Start() + // If this case drives its error in over the wire, do + // so now that the goroutine is running. + if tt.deliverMsg != nil { + tt.deliverMsg(t, syncer, msgChan) + } + // Wait long enough that an endless loop would // accumulate many attempts. With the fix, we should // only see 1-3 attempts. Without the fix, we'd see @@ -2613,6 +2841,12 @@ func TestGossipSyncerStateHandlerErrors(t *testing.T) { attemptCount, ) + // Verify the terminal state, if this case cares about + // it, before we signal the syncer to quit. + if tt.assertOutcome != nil { + tt.assertOutcome(t, syncer) + } + // Verify the syncer exits cleanly without hanging. assertSyncerExitsCleanly(t, syncer, 2*time.Second) }) diff --git a/docs/release-notes/release-notes-0.20.2.md b/docs/release-notes/release-notes-0.20.2.md index e426b35df..5e80bcbc0 100644 --- a/docs/release-notes/release-notes-0.20.2.md +++ b/docs/release-notes/release-notes-0.20.2.md @@ -36,6 +36,16 @@ distinguish off-chain auto-fail heights from on-chain settlement deadlines, or `AutoFailHeight()` if they only need the legacy flattened value. +* [Bounded the memory used while syncing the channel + graph](https://github.com/lightningnetwork/lnd/pull/10992). A peer replying + to our `query_channel_range` could previously make us buffer an + unpredictable number of short channel IDs, as the only limit was a coarse + 67MB cap on the bytes a single zlib-compressed reply could decompress to. + Replies are now capped at a precise number of short channel IDs, both + per-message and in aggregate across a single query, and the accumulated + reply state is released as soon as any reply fails validation so that a + peer cannot pin it by deliberately forcing an error. + # New Features ## Functional Enhancements @@ -102,4 +112,5 @@ # Contributors (Alphabetical Order) * Erick Cestari +* Olaoluwa Osuntokun * Ziggie From be784bd373361de00f3f066c10b8ae7e58289021 Mon Sep 17 00:00:00 2001 From: ziggie Date: Tue, 4 Aug 2026 21:11:21 -0300 Subject: [PATCH 115/134] invoices: refine update handling (cherry picked from commit 6be6350ec4a1ee67f04a96c4c12e649eaa3aeccd) --- invoices/invoiceregistry_test.go | 44 ++++ invoices/update.go | 83 ++++++- invoices/update_invoice_test.go | 360 +++++++++++++++++++++++++++++++ 3 files changed, 476 insertions(+), 11 deletions(-) diff --git a/invoices/invoiceregistry_test.go b/invoices/invoiceregistry_test.go index 5e13f8735..94e894a21 100644 --- a/invoices/invoiceregistry_test.go +++ b/invoices/invoiceregistry_test.go @@ -98,6 +98,10 @@ func TestInvoiceRegistry(t *testing.T) { name: "AMPWithoutMPPPayload", test: testAMPWithoutMPPPayload, }, + { + name: "AMPWithoutMPPExistingInvoice", + test: testAMPWithoutMPPExistingInvoice, + }, { name: "SpontaneousAmpPayment", test: testSpontaneousAmpPayment, @@ -1878,6 +1882,46 @@ func testAMPWithoutMPPPayload(t *testing.T, checkFailResolution(t, resolution, invpkg.ResultAmpError) } +// testAMPWithoutMPPExistingInvoice checks AMP handling for an existing invoice +// when spontaneous AMP payments are disabled. +func testAMPWithoutMPPExistingInvoice(t *testing.T, + makeDB func(t *testing.T) (invpkg.InvoiceDB, *clock.TestClock)) { + + t.Parallel() + defer timeout()() + + cfg := defaultRegistryConfig() + cfg.AcceptAMP = false + ctx := newTestContext(t, &cfg, makeDB) + ctxb := t.Context() + + invoice := newInvoice(t, false, true) + _, err := ctx.registry.AddInvoice( + ctxb, invoice, testInvoicePaymentHash, + ) + require.NoError(t, err) + + payload := &mockPayload{ + amp: record.NewAMP([32]byte{}, [32]byte{}, 0), + } + + hodlChan := make(chan interface{}, 1) + resolution, err := ctx.registry.NotifyExitHopHtlc( + testInvoicePaymentHash, invoice.Terms.Value, testHtlcExpiry, + testCurrentHeight, getCircuitKey(10), hodlChan, nil, payload, + ) + require.NoError(t, err) + require.NotNil(t, resolution) + checkFailResolution(t, resolution, invpkg.ResultAmpError) + + storedInvoice, err := ctx.registry.LookupInvoice( + ctxb, testInvoicePaymentHash, + ) + require.NoError(t, err) + require.Equal(t, invpkg.ContractOpen, storedInvoice.State) + require.Empty(t, storedInvoice.Htlcs) +} + // testSpontaneousAmpPayment tests receiving a spontaneous AMP payment with both // valid and invalid reconstructions. func testSpontaneousAmpPayment(t *testing.T, diff --git a/invoices/update.go b/invoices/update.go index 6f7a34f4c..277636acf 100644 --- a/invoices/update.go +++ b/invoices/update.go @@ -128,16 +128,36 @@ func resolveReplayedHtlc(ctx *invoiceUpdateCtx, inv *Invoice) (bool, return true, ctx.acceptRes(resultReplayToAccepted), nil case HtlcStateSettled: - pre := inv.Terms.PaymentPreimage + var preimage *lntypes.Preimage + switch { + // AMP invoices store a separate preimage on each HTLC. + case inv.IsAMP(): + if htlc.AMP == nil || htlc.AMP.Preimage == nil { + return true, nil, ErrHTLCPreimageMissing + } - // Terms.PaymentPreimage will be nil for AMP invoices. - // Set it to the HTLCs AMP Preimage instead. - if pre == nil { - pre = htlc.AMP.Preimage + preimage = htlc.AMP.Preimage + if htlc.AMP.Hash != ctx.hash || + !preimage.Matches(htlc.AMP.Hash) { + + return true, nil, ErrHTLCPreimageMismatch + } + + // Regular invoices store their preimage at the invoice level. + case inv.Terms.PaymentPreimage == nil: + return true, nil, errors.New( + "settled invoice missing payment preimage", + ) + + default: + preimage = inv.Terms.PaymentPreimage + if !preimage.Matches(ctx.hash) { + return true, nil, ErrInvoicePreimageMismatch + } } return true, ctx.settleRes( - *pre, + *preimage, ResultReplayToSettled, ), nil @@ -155,6 +175,12 @@ func resolveReplayedHtlc(ctx *invoiceUpdateCtx, inv *Invoice) (bool, func updateInvoice(ctx *invoiceUpdateCtx, inv *Invoice) ( *InvoiceUpdateDesc, HtlcResolution, error) { + // AMP records are processed together with their corresponding MPP + // payload. + if ctx.amp != nil && ctx.mpp == nil { + return nil, ctx.failRes(ResultAmpError), nil + } + // If no MPP payload was provided, then we expect this to be a keysend, // or a payment to an invoice created before we started to require the // MPP payload. @@ -414,6 +440,12 @@ func reconstructAMPPreimages(ctx *invoiceUpdateCtx, func updateLegacy(ctx *invoiceUpdateCtx, inv *Invoice) (*InvoiceUpdateDesc, HtlcResolution, error) { + // AMP invoices use the MPP update path, where each HTLC's AMP data is + // available for processing. + if inv.IsAMP() { + return nil, ctx.failRes(ResultHtlcInvoiceTypeMismatch), nil + } + // If the invoice is already canceled, there is no further // checking to do. if inv.State == ContractCanceled { @@ -432,12 +464,11 @@ func updateLegacy(ctx *invoiceUpdateCtx, // if we're in this method it means that the remote party didn't supply // the expected payload. However if this is a keysend payment, then // we'll permit it to pass. - _, isKeySend := ctx.customRecords[record.KeySendType] invoiceFeatures := inv.Terms.Features paymentAddrRequired := invoiceFeatures.RequiresFeature( lnwire.PaymentAddrRequired, ) - if !isKeySend && paymentAddrRequired { + if !isValidKeySend(ctx) && paymentAddrRequired { log.Warnf("Payment to pay_hash=%v doesn't include MPP "+ "payload, rejecting", ctx.hash) return nil, ctx.failRes(ResultAddressMismatch), nil @@ -489,8 +520,15 @@ func updateLegacy(ctx *invoiceUpdateCtx, return &update, ctx.acceptRes(resultDuplicateToAccepted), nil case ContractSettled: + // Legacy settlement uses the invoice-level payment preimage. + preimage := inv.Terms.PaymentPreimage + if preimage == nil { + return nil, ctx.failRes(ResultHtlcInvoiceTypeMismatch), + nil + } + return &update, ctx.settleRes( - *inv.Terms.PaymentPreimage, ResultDuplicateToSettled, + *preimage, ResultDuplicateToSettled, ), nil } @@ -504,12 +542,35 @@ func updateLegacy(ctx *invoiceUpdateCtx, return &update, ctx.acceptRes(resultAccepted), nil } + // A legacy invoice provides its settlement preimage at the invoice + // level. + preimage := inv.Terms.PaymentPreimage + if preimage == nil { + return nil, ctx.failRes(ResultHtlcInvoiceTypeMismatch), nil + } + update.State = &InvoiceStateUpdateDesc{ NewState: ContractSettled, - Preimage: inv.Terms.PaymentPreimage, + Preimage: preimage, } return &update, ctx.settleRes( - *inv.Terms.PaymentPreimage, ResultSettled, + *preimage, ResultSettled, ), nil } + +// isValidKeySend reports whether the custom records contain a keysend +// preimage whose hash matches the payment hash. +func isValidKeySend(ctx *invoiceUpdateCtx) bool { + preimageBytes, ok := ctx.customRecords[record.KeySendType] + if !ok { + return false + } + + preimage, err := lntypes.MakePreimage(preimageBytes) + if err != nil { + return false + } + + return preimage.Hash() == ctx.hash +} diff --git a/invoices/update_invoice_test.go b/invoices/update_invoice_test.go index 6069fbecd..64ec0f1a0 100644 --- a/invoices/update_invoice_test.go +++ b/invoices/update_invoice_test.go @@ -764,3 +764,363 @@ func testUpdateHTLC(t *testing.T, test updateHTLCTest, now time.Time) { require.Equal(t, test.expErr, err) require.Equal(t, test.output, *htlc) } + +// TestResolveReplayedHtlcSettled checks preimage selection for settled HTLC +// replays. +func TestResolveReplayedHtlcSettled(t *testing.T) { + t.Parallel() + + const missingPreimageErr = "settled invoice missing payment preimage" + + validPreimage := lntypes.Preimage{1} + otherPreimage := lntypes.Preimage{2} + validHash := validPreimage.Hash() + otherHash := otherPreimage.Hash() + setID := [32]byte{3} + ampRecord := record.NewAMP([32]byte{4}, setID, 5) + ampFeatures := lnwire.NewFeatureVector( + lnwire.NewRawFeatureVector(lnwire.AMPRequired), + lnwire.Features, + ) + + tests := []struct { + name string + invoicePreimage *lntypes.Preimage + invoiceFeatures *lnwire.FeatureVector + htlcAMP *InvoiceHtlcAMPData + paymentHash lntypes.Hash + expectedPreimage *lntypes.Preimage + expectedErr error + expectedErrText string + }{ + { + name: "regular invoice", + invoicePreimage: &validPreimage, + paymentHash: validHash, + expectedPreimage: &validPreimage, + }, + { + name: "regular invoice missing preimage", + paymentHash: validHash, + expectedErrText: missingPreimageErr, + }, + { + name: "regular invoice preimage mismatch", + invoicePreimage: &otherPreimage, + paymentHash: validHash, + expectedErr: ErrInvoicePreimageMismatch, + }, + { + name: "AMP invoice", + invoiceFeatures: ampFeatures, + htlcAMP: &InvoiceHtlcAMPData{ + Record: *ampRecord, + Hash: validHash, + Preimage: &validPreimage, + }, + paymentHash: validHash, + expectedPreimage: &validPreimage, + }, + { + name: "AMP invoice missing HTLC data", + invoiceFeatures: ampFeatures, + paymentHash: validHash, + expectedErr: ErrHTLCPreimageMissing, + }, + { + name: "AMP invoice missing preimage", + invoiceFeatures: ampFeatures, + htlcAMP: &InvoiceHtlcAMPData{ + Record: *ampRecord, + Hash: validHash, + }, + paymentHash: validHash, + expectedErr: ErrHTLCPreimageMissing, + }, + { + name: "AMP invoice preimage mismatch", + invoiceFeatures: ampFeatures, + htlcAMP: &InvoiceHtlcAMPData{ + Record: *ampRecord, + Hash: validHash, + Preimage: &otherPreimage, + }, + paymentHash: validHash, + expectedErr: ErrHTLCPreimageMismatch, + }, + { + name: "AMP invoice hash mismatch", + invoiceFeatures: ampFeatures, + htlcAMP: &InvoiceHtlcAMPData{ + Record: *ampRecord, + Hash: otherHash, + Preimage: &otherPreimage, + }, + paymentHash: validHash, + expectedErr: ErrHTLCPreimageMismatch, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + circuitKey := CircuitKey{HtlcID: 1} + ctx := &invoiceUpdateCtx{ + hash: test.paymentHash, + circuitKey: circuitKey, + } + invoice := &Invoice{ + Terms: ContractTerm{ + PaymentPreimage: test.invoicePreimage, + Features: test.invoiceFeatures, + }, + Htlcs: map[CircuitKey]*InvoiceHTLC{ + circuitKey: { + State: HtlcStateSettled, + AMP: test.htlcAMP, + }, + }, + } + + replayed, resolution, err := resolveReplayedHtlc( + ctx, invoice, + ) + require.True(t, replayed) + + switch { + case test.expectedErr != nil: + require.ErrorIs(t, err, test.expectedErr) + require.Nil(t, resolution) + + case test.expectedErrText != "": + require.EqualError(t, err, test.expectedErrText) + require.Nil(t, resolution) + + default: + require.NoError(t, err) + requireSettleResolution( + t, resolution, ResultReplayToSettled, + ) + settleResolution, ok := + resolution.(*HtlcSettleResolution) + require.True(t, ok) + require.Equal( + t, *test.expectedPreimage, + settleResolution.Preimage, + ) + } + }) + } +} + +// TestUpdateInvoiceRejectsAmpWithoutMPP checks that AMP records follow the MPP +// update path. +func TestUpdateInvoiceRejectsAmpWithoutMPP(t *testing.T) { + t.Parallel() + + ctx, invoice := newLegacyUpdateTestContext(t, ContractOpen) + ctx.amp = record.NewAMP([32]byte{1}, [32]byte{2}, 3) + + update, resolution, err := updateInvoice(ctx, invoice) + require.NoError(t, err) + require.Nil(t, update) + requireFailResolution(t, resolution, ResultAmpError) +} + +// TestUpdateInvoiceRejectsAmpInvoiceInLegacyPath checks that AMP invoices are +// handled by the MPP update path. +func TestUpdateInvoiceRejectsAmpInvoiceInLegacyPath(t *testing.T) { + t.Parallel() + + ctx, invoice := newLegacyUpdateTestContext(t, ContractOpen) + invoice.Terms.PaymentPreimage = nil + invoice.Terms.Features = lnwire.NewFeatureVector( + lnwire.NewRawFeatureVector( + lnwire.TLVOnionPayloadOptional, + lnwire.PaymentAddrOptional, + lnwire.AMPRequired, + ), + lnwire.Features, + ) + + update, resolution, err := updateInvoice(ctx, invoice) + require.NoError(t, err) + require.Nil(t, update) + requireFailResolution(t, resolution, ResultHtlcInvoiceTypeMismatch) +} + +// TestUpdateLegacyRejectsNilPreimageSettle checks the outcome when a legacy +// settlement has no invoice-level preimage. +func TestUpdateLegacyRejectsNilPreimageSettle(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + state ContractState + }{ + { + name: "new settle", + state: ContractOpen, + }, + { + name: "duplicate settled", + state: ContractSettled, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + ctx, invoice := newLegacyUpdateTestContext( + t, test.state, + ) + invoice.Terms.PaymentPreimage = nil + + update, resolution, err := updateLegacy(ctx, invoice) + require.NoError(t, err) + require.Nil(t, update) + requireFailResolution( + t, resolution, ResultHtlcInvoiceTypeMismatch, + ) + }) + } +} + +// TestUpdateLegacyValidatesKeysendRecord checks that the keysend record is +// well-formed and corresponds to the payment hash. +func TestUpdateLegacyValidatesKeysendRecord(t *testing.T) { + t.Parallel() + + validPreimage := lntypes.Preimage{1} + invalidPreimage := lntypes.Preimage{2} + + tests := []struct { + name string + keysendRecord []byte + expectFail bool + expectedResult FailResolutionResult + }{ + { + name: "missing keysend", + expectFail: true, + expectedResult: ResultAddressMismatch, + }, + { + name: "invalid keysend length", + keysendRecord: []byte{1, 2, 3}, + expectFail: true, + expectedResult: ResultAddressMismatch, + }, + { + name: "wrong keysend preimage", + keysendRecord: invalidPreimage[:], + expectFail: true, + expectedResult: ResultAddressMismatch, + }, + { + name: "valid keysend", + keysendRecord: validPreimage[:], + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + ctx, invoice := newLegacyUpdateTestContext( + t, ContractOpen, + ) + ctx.hash = validPreimage.Hash() + ctx.customRecords = make(record.CustomSet) + invoice.Terms.PaymentPreimage = &validPreimage + invoice.Terms.Features = lnwire.NewFeatureVector( + lnwire.NewRawFeatureVector( + lnwire.TLVOnionPayloadRequired, + lnwire.PaymentAddrRequired, + ), + lnwire.Features, + ) + + if test.keysendRecord != nil { + ctx.customRecords[record.KeySendType] = + test.keysendRecord + } + + update, resolution, err := updateLegacy(ctx, invoice) + require.NoError(t, err) + + if test.expectFail { + require.Nil(t, update) + requireFailResolution( + t, resolution, test.expectedResult, + ) + + return + } + + require.NotNil(t, update) + requireSettleResolution(t, resolution, ResultSettled) + }) + } +} + +// newLegacyUpdateTestContext creates a minimal legacy invoice and update +// context for exercising update selection and settlement outcomes. +func newLegacyUpdateTestContext(t *testing.T, + state ContractState) (*invoiceUpdateCtx, *Invoice) { + + t.Helper() + + preimage := lntypes.Preimage{1} + payHash := preimage.Hash() + + ctx := &invoiceUpdateCtx{ + hash: payHash, + circuitKey: CircuitKey{HtlcID: 1}, + amtPaid: lnwire.MilliSatoshi(1000), + expiry: 40, + currentHeight: 10, + finalCltvRejectDelta: 10, + customRecords: make(record.CustomSet), + wireCustomRecords: make(lnwire.CustomRecords), + } + + invoice := &Invoice{ + State: state, + Terms: ContractTerm{ + FinalCltvDelta: 10, + PaymentPreimage: &preimage, + Value: 1000, + Features: lnwire.NewFeatureVector( + nil, lnwire.Features, + ), + }, + Htlcs: make(map[CircuitKey]*InvoiceHTLC), + } + + return ctx, invoice +} + +// requireFailResolution checks the resolution type and its reported outcome. +func requireFailResolution(t *testing.T, resolution HtlcResolution, + expected FailResolutionResult) { + + t.Helper() + + failResolution, ok := resolution.(*HtlcFailResolution) + require.True(t, ok) + require.Equal(t, expected, failResolution.Outcome) +} + +// requireSettleResolution checks the resolution type and its reported outcome. +func requireSettleResolution(t *testing.T, resolution HtlcResolution, + expected SettleResolutionResult) { + + t.Helper() + + settleResolution, ok := resolution.(*HtlcSettleResolution) + require.True(t, ok) + require.Equal(t, expected, settleResolution.Outcome) +} From fab4905aa5c158f5e75b9f3d21d497c1ef5763e9 Mon Sep 17 00:00:00 2001 From: ziggie Date: Tue, 4 Aug 2026 21:12:00 -0300 Subject: [PATCH 116/134] docs: update 0.21.2 release notes (cherry picked from commit 758bbb8e52dbd5c84d45c6dde5301d8290e065a2) --- docs/release-notes/release-notes-0.20.2.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/release-notes/release-notes-0.20.2.md b/docs/release-notes/release-notes-0.20.2.md index 5e80bcbc0..364fd1f88 100644 --- a/docs/release-notes/release-notes-0.20.2.md +++ b/docs/release-notes/release-notes-0.20.2.md @@ -46,6 +46,11 @@ reply state is released as soon as any reply fails validation so that a peer cannot pin it by deliberately forcing an error. +* [Refined invoice update + handling](https://github.com/lightningnetwork/lnd/pull/11024) across MPP, AMP, + and legacy payment paths, including keysend records and preimage-dependent + settlement outcomes. + # New Features ## Functional Enhancements From 5ea52afa687d9fa5f8a0b81848f0768e27ff84d3 Mon Sep 17 00:00:00 2001 From: ziggie Date: Wed, 5 Aug 2026 23:20:23 -0300 Subject: [PATCH 117/134] contractcourt: retain deadline across contest resolution Forward the supplied incoming HTLC expiry from the outgoing contest resolver to its embedded timeout resolver. This keeps the deadline available when resolution transitions after the outgoing HTLC expires. (cherry picked from commit f77606851f34cd1bbf0c8f1e8cbc2e95601732da) --- .../htlc_outgoing_contest_resolver.go | 8 +++-- .../htlc_outgoing_contest_resolver_test.go | 35 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/contractcourt/htlc_outgoing_contest_resolver.go b/contractcourt/htlc_outgoing_contest_resolver.go index 9e94587cc..973051ae6 100644 --- a/contractcourt/htlc_outgoing_contest_resolver.go +++ b/contractcourt/htlc_outgoing_contest_resolver.go @@ -229,10 +229,14 @@ func (h *htlcOutgoingContestResolver) Encode(w io.Writer) error { return h.htlcTimeoutResolver.Encode(w) } -// SupplementDeadline does nothing for an incoming htlc resolver. +// SupplementDeadline forwards the incoming HTLC's expiry height to the inner +// timeout resolver. This resolver morphs into that timeout resolver once the +// outgoing HTLC expires on-chain, so the deadline is retained across the +// transition. // // NOTE: Part of the htlcContractResolver interface. -func (h *htlcOutgoingContestResolver) SupplementDeadline(_ fn.Option[int32]) { +func (h *htlcOutgoingContestResolver) SupplementDeadline(d fn.Option[int32]) { + h.htlcTimeoutResolver.SupplementDeadline(d) } // newOutgoingContestResolverFromReader attempts to decode an encoded ContractResolver diff --git a/contractcourt/htlc_outgoing_contest_resolver_test.go b/contractcourt/htlc_outgoing_contest_resolver_test.go index 625df60bf..fedc84801 100644 --- a/contractcourt/htlc_outgoing_contest_resolver_test.go +++ b/contractcourt/htlc_outgoing_contest_resolver_test.go @@ -7,6 +7,7 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/lightningnetwork/lnd/chainntnfs" "github.com/lightningnetwork/lnd/channeldb" + "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/graph/db/models" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/kvdb" @@ -20,6 +21,10 @@ import ( const ( outgoingContestHtlcExpiry = 110 + + // outgoingContestIncomingHtlcExpiry is kept distinct from the outgoing + // HTLC expiry to verify that the supplied value is retained. + outgoingContestIncomingHtlcExpiry = 144 ) // TestHtlcOutgoingResolverTimeout tests resolution of an offered htlc that @@ -116,6 +121,36 @@ type resolveResult struct { nextResolver ContractResolver } +// TestHtlcOutgoingResolverSupplementDeadline checks that the outgoing contest +// resolver forwards the incoming HTLC deadline to the timeout resolver it +// transitions into once the outgoing HTLC expires on-chain. +func TestHtlcOutgoingResolverSupplementDeadline(t *testing.T) { + t.Parallel() + defer timeout()() + + ctx := newOutgoingResolverTestContext(t) + + // Initially the embedded timeout resolver carries no deadline. + require.True(t, ctx.resolver.incomingHTLCExpiryHeight.IsNone()) + + // Supply the deadline through the contest resolver, as the channel + // arbitrator does when constructing the resolver. + deadline := fn.Some(int32(outgoingContestIncomingHtlcExpiry)) + ctx.resolver.SupplementDeadline(deadline) + + // Drive the contest resolver to the point where it returns the embedded + // timeout resolver. + ctx.resolve() + ctx.notifyEpoch(outgoingContestHtlcExpiry) + + result := <-ctx.resolverResultChan + require.NoError(t, result.err) + + timeoutRes, ok := result.nextResolver.(*htlcTimeoutResolver) + require.True(t, ok, "expected htlcTimeoutResolver") + require.Equal(t, deadline, timeoutRes.incomingHTLCExpiryHeight) +} + type outgoingResolverTestContext struct { resolver *htlcOutgoingContestResolver notifier *mock.ChainNotifier From f93ca9548195b6f6902d990649ebafb7b9b19328 Mon Sep 17 00:00:00 2001 From: ziggie Date: Thu, 6 Aug 2026 08:48:41 -0300 Subject: [PATCH 118/134] docs: update release notes --- docs/release-notes/release-notes-0.20.2.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/release-notes/release-notes-0.20.2.md b/docs/release-notes/release-notes-0.20.2.md index 364fd1f88..2c39d95ca 100644 --- a/docs/release-notes/release-notes-0.20.2.md +++ b/docs/release-notes/release-notes-0.20.2.md @@ -51,6 +51,11 @@ and legacy payment paths, including keysend records and preimage-dependent settlement outcomes. +* Outgoing contest resolvers now [retain the corresponding incoming HTLC + expiry](https://github.com/lightningnetwork/lnd/pull/11032) when transitioning + to timeout resolution, allowing the sweeper to continue using an + expiry-aware confirmation target. + # New Features ## Functional Enhancements From 316c2d936b859f2804e9f830ab88f587a7a21392 Mon Sep 17 00:00:00 2001 From: ziggie Date: Wed, 5 Aug 2026 18:57:18 -0300 Subject: [PATCH 119/134] htlcswitch: bound intercepted auto-fail height The interceptor exposes its derived auto-fail height as an int32. Calculate the height in int64 and fail forwards whose deadline cannot be represented with expiry_too_far. Add coverage for the range check and subsequent forward handling. (cherry picked from commit ae3f4aff48595f16ccec33fc54c022ae3c0e82a3) --- htlcswitch/interceptable_switch.go | 34 ++++++++++++-- htlcswitch/switch_test.go | 73 +++++++++++++++++++++++++++++- 2 files changed, 101 insertions(+), 6 deletions(-) diff --git a/htlcswitch/interceptable_switch.go b/htlcswitch/interceptable_switch.go index 9ef686a65..eea81078e 100644 --- a/htlcswitch/interceptable_switch.go +++ b/htlcswitch/interceptable_switch.go @@ -4,6 +4,7 @@ import ( "crypto/sha256" "errors" "fmt" + "math" "sync" "sync/atomic" @@ -661,13 +662,35 @@ func (s *InterceptableSwitch) removeOnChainIntercept(key models.CircuitKey) { } } -// handleExpired checks that the htlc isn't too close to the channel -// force-close broadcast height. If it is, it is cancelled back. +// handleExpired checks that the htlc's expiry is within the range that can be +// offered to the interceptor. Expiries near the channel force-close broadcast +// height and expiries whose auto-fail height cannot be represented are failed +// back. func (s *InterceptableSwitch) handleExpired(fwd *interceptedForward) ( bool, error) { height := uint32(s.currentHeight) - if fwd.packet.incomingTimeout >= height+s.cltvInterceptDelta { + incomingTimeout := fwd.packet.incomingTimeout + + // The interceptor auto-fail height is the incoming timeout less the + // reject delta and is exposed as an int32 block height. Calculate it in + // int64 so that we can check the representable range before conversion. + autoFailHeight := int64(incomingTimeout) - int64(s.cltvRejectDelta) + if autoFailHeight > math.MaxInt32 { + log.Debugf("Interception rejected because htlc expires too "+ + "far in the future: circuit=%v, height=%v, "+ + "incoming_timeout=%v", fwd.packet.inKey(), height, + incomingTimeout) + + err := fwd.FailWithCode(lnwire.CodeExpiryTooFar) + if err != nil { + return false, err + } + + return true, nil + } + + if incomingTimeout >= height+s.cltvInterceptDelta { return false, nil } @@ -675,7 +698,7 @@ func (s *InterceptableSwitch) handleExpired(fwd *interceptedForward) ( "expires too soon: circuit=%v, "+ "height=%v, incoming_timeout=%v", fwd.packet.inKey(), height, - fwd.packet.incomingTimeout) + incomingTimeout) err := fwd.FailWithCode( lnwire.CodeExpiryTooSoon, @@ -859,6 +882,9 @@ func (f *interceptedForward) FailWithCode(code lnwire.FailCode) error { failureMsg = lnwire.NewExpiryTooSoon(*update) + case lnwire.CodeExpiryTooFar: + failureMsg = &lnwire.FailExpiryTooFar{} + default: return ErrUnsupportedFailureCode } diff --git a/htlcswitch/switch_test.go b/htlcswitch/switch_test.go index 3c8b9fea8..6eab85dbf 100644 --- a/htlcswitch/switch_test.go +++ b/htlcswitch/switch_test.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "math" mrand "math/rand" "reflect" "testing" @@ -3894,15 +3895,19 @@ func assertOutgoingLinkReceive(t *testing.T, targetLink *mockChannelLink, } func assertOutgoingLinkReceiveIntercepted(t *testing.T, - targetLink *mockChannelLink) { + targetLink *mockChannelLink) *htlcPacket { t.Helper() select { - case <-targetLink.packets: + case packet := <-targetLink.packets: + return packet + case <-time.After(time.Second): t.Fatal("request was not propagated to destination") } + + return nil } type interceptableSwitchTestContext struct { @@ -4369,6 +4374,70 @@ func TestInterceptableSwitchWatchDog(t *testing.T) { })) } +// TestInterceptableSwitchExpiryTooFar asserts that an intercepted forward with +// an incoming expiry outside the supported auto-fail height range is failed +// back and that subsequent forwards can still be intercepted. +func TestInterceptableSwitchExpiryTooFar(t *testing.T) { + t.Parallel() + + c := newInterceptableSwitchTestContext(t) + defer c.finish() + + notifier := &mock.ChainNotifier{ + EpochChan: make(chan *chainntnfs.BlockEpoch, 1), + } + notifier.EpochChan <- &chainntnfs.BlockEpoch{Height: testStartingHeight} + + switchForwardInterceptor, err := NewInterceptableSwitch( + &InterceptableSwitchConfig{ + Switch: c.s, + CltvRejectDelta: c.cltvRejectDelta, + CltvInterceptDelta: c.cltvInterceptDelta, + Notifier: notifier, + }, + ) + require.NoError(t, err) + require.NoError(t, switchForwardInterceptor.Start()) + + switchForwardInterceptor.SetInterceptor( + c.forwardInterceptor.InterceptForwardHtlc, + ) + linkQuit := make(chan struct{}) + + packet := c.createTestPacket() + packet.incomingTimeout = math.MaxUint32 + + err = switchForwardInterceptor.ForwardPackets(linkQuit, false, packet) + require.NoError(t, err, "can't forward htlc packet") + + // The forward is failed back rather than being intercepted or sent to + // the outgoing link. + assertOutgoingLinkReceive(t, c.bobChannelLink, false) + failPacket := assertOutgoingLinkReceiveIntercepted( + t, c.aliceChannelLink, + ) + failHtlc, ok := failPacket.htlc.(*lnwire.UpdateFailHTLC) + require.True(t, ok) + + fwdErr, err := newMockDeobfuscator().DecryptError(failHtlc.Reason) + require.NoError(t, err) + require.IsType(t, &lnwire.FailExpiryTooFar{}, fwdErr.WireMessage()) + assertNumCircuits(t, c.s, 0, 0) + + // A later forward with a representable auto-fail height is intercepted + // normally. + require.NoError(t, switchForwardInterceptor.ForwardPackets( + linkQuit, false, c.createTestPacket(), + )) + + intercepted := c.forwardInterceptor.getIntercepted() + require.Equal(t, + int32(testStartingHeight+c.cltvInterceptDelta+1- + c.cltvRejectDelta), + intercepted.AutoFailHeight(), + ) +} + // TestSwitchDustForwarding tests that the switch properly fails HTLC's which // have incoming or outgoing links that breach their fee thresholds. func TestSwitchDustForwarding(t *testing.T) { From c4da2135327a9b3f90ee6ab8fa42f8250fb81879 Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Mon, 9 Mar 2026 10:41:14 +0100 Subject: [PATCH 120/134] chore: fix linter issues in brontide.go Post merge of #10089, a linter issues was introduced in `brontide.go`. This commit fixes that issue. (cherry picked from commit e8074935d9df709070ebb631210732d987e93dad) --- peer/brontide.go | 1 + 1 file changed, 1 insertion(+) diff --git a/peer/brontide.go b/peer/brontide.go index 9191cbb2e..aac82005f 100644 --- a/peer/brontide.go +++ b/peer/brontide.go @@ -4234,6 +4234,7 @@ func (p *Brontide) handleLocalCloseReq(req *htlcswitch.ChanClose) { "unknown", chanID) p.log.Errorf(err.Error()) req.Err <- err + return } From 93230c84e4bcf431292b711e6cc3dda14b795580 Mon Sep 17 00:00:00 2001 From: ziggie Date: Wed, 5 Aug 2026 19:15:08 -0300 Subject: [PATCH 121/134] docs: add interceptor auto-fail height release note --- docs/release-notes/release-notes-0.20.2.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/release-notes/release-notes-0.20.2.md b/docs/release-notes/release-notes-0.20.2.md index 2c39d95ca..ddca949a3 100644 --- a/docs/release-notes/release-notes-0.20.2.md +++ b/docs/release-notes/release-notes-0.20.2.md @@ -82,6 +82,10 @@ will now have its `UpdateChannelPolicy` request rejected, and must lower the value accordingly below the specified maximum. +* [The HTLC forward interceptor now validates](https://github.com/lightningnetwork/lnd/pull/11028) + that derived auto-fail heights are within the supported range before they are + exposed through the interceptor API. + ## RPC Updates * `ForwardHtlcInterceptRequest.outgoing_requested_chan_id` now holds a reserved From 8d4e34ec957fae0006703a9e616f7cf87d377d90 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Mon, 3 Aug 2026 16:10:21 -0700 Subject: [PATCH 122/134] lnwallet: make DustLimitForSize total over the sizes it can be handed In this commit, we have DustLimitForSize fall back to the generic witness dust threshold for any script size that doesn't match one of the well-known templates. The size switch covered P2WPKH, P2WSH, P2SH, P2PKH, and the explicit unknown-witness size, and treated every other length as unreachable. That's a narrower assumption than the callers can actually make good on: a witness program for versions 1 through 16 carries a program of anywhere from 2 to 40 bytes, so its serialized length won't always land on one of those exact values. The dust calculation only needs a representative output of roughly the right shape, and the unknown-witness pricing is the conservative choice among the ones we have, so we make it the default. That leaves the helper well defined across the whole range of sizes callers can pass it, including scripts carrying witness versions we don't know about yet. --- lnwallet/parameters.go | 14 ++++++++------ lnwallet/parameters_test.go | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/lnwallet/parameters.go b/lnwallet/parameters.go index 41509ef9a..a5e476359 100644 --- a/lnwallet/parameters.go +++ b/lnwallet/parameters.go @@ -41,8 +41,10 @@ func DefaultRoutingFeeLimitForAmount(a lnwire.MilliSatoshi) lnwire.MilliSatoshi // DustLimitForSize retrieves the dust limit for a given pkscript size. Given // the size, it automatically determines whether the script is a witness script -// or not. It calls btcd's GetDustThreshold method under the hood. It must be -// called with a proper size parameter or else a panic occurs. +// or not. It calls btcd's GetDustThreshold method under the hood. Any size that +// doesn't map to one of the well-known templates is treated as a generic +// witness output, so the helper stays well-defined for arbitrary (including +// future witness-version) script lengths. func DustLimitForSize(scriptSize int) btcutil.Amount { var ( dustlimit btcutil.Amount @@ -66,11 +68,11 @@ func DustLimitForSize(scriptSize int) btcutil.Amount { case input.P2PKHSize: pkscript, _ = input.GenerateP2PKH([]byte{}) - case input.UnknownWitnessSize: - pkscript, _ = input.GenerateUnknownWitness() - + // Any other length (the explicit UnknownWitnessSize, or an otherwise + // unrecognized size) is priced as a generic witness output rather than + // treated as a hard error. default: - panic("invalid script size") + pkscript, _ = input.GenerateUnknownWitness() } // Call GetDustThreshold with a TxOut containing the generated diff --git a/lnwallet/parameters_test.go b/lnwallet/parameters_test.go index 3cee8f3e6..9ec3fdcb8 100644 --- a/lnwallet/parameters_test.go +++ b/lnwallet/parameters_test.go @@ -82,6 +82,21 @@ func TestDustLimitForSize(t *testing.T) { size: input.UnknownWitnessSize, expectedLimit: btcutil.Amount(354), }, + { + // An arbitrary short length that matches no known + // template is priced as a generic witness output + // rather than treated as an error. + name: "arbitrary small size", + size: 7, + expectedLimit: btcutil.Amount(354), + }, + { + // The largest witness program length is also handled + // as a generic witness output. + name: "arbitrary large witness size", + size: 42, + expectedLimit: btcutil.Amount(354), + }, } for _, test := range tests { From f51ec036dcd10106dbf005211e3af27b65d347c1 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Tue, 4 Aug 2026 11:05:30 -0700 Subject: [PATCH 123/134] lnwallet/chancloser: validate delivery scripts in the RBF closer In this commit, we make the RBF co-op closer validate the remote party's delivery script in all cases, matching what the negotiation closer already does. Previously we only ran the check when we had an upfront shutdown script on record for the peer, so a peer that never committed to an upfront script could hand us a delivery script that we'd stash and carry through the rest of the close flow without ever looking at it. We now always call validateShutdownScript with the (possibly nil) upfront script: a nil upfront script still runs the well-formedness check on the peer's script, and a non-nil one additionally enforces the exact match, same as before. We also require the script to be present. The wire format puts no lower bound on the address length, and validateShutdownScript treats an absent peer script as nothing to check, so an empty one passed validation by default rather than on its merits. Both entry points now go through one helper that insists on a script before running the usual checks over it, which also covers a CloserScript swapped in mid-negotiation via ClosingComplete rather than letting that one go unchecked. The delivery-form coverage is spelled out in the tests: the spec dropped p2pkh and p2sh for co-op closes to keep the dust calculations uniform, and we don't implement the OP_RETURN form that option_simple_close allows, so all of those are rejected along with an empty or malformed script. --- lnwallet/chancloser/rbf_coop_test.go | 93 ++++++++++++++++++++- lnwallet/chancloser/rbf_coop_transitions.go | 54 +++++++++--- 2 files changed, 131 insertions(+), 16 deletions(-) diff --git a/lnwallet/chancloser/rbf_coop_test.go b/lnwallet/chancloser/rbf_coop_test.go index 088f7f4e1..98a5c2c4a 100644 --- a/lnwallet/chancloser/rbf_coop_test.go +++ b/lnwallet/chancloser/rbf_coop_test.go @@ -851,8 +851,6 @@ func newCloser(t *testing.T, cfg *harnessCfg) *rbfCloserTestHarness { // ChannelActive state. func TestRbfChannelActiveTransitions(t *testing.T) { ctx := t.Context() - localAddr := lnwire.DeliveryAddress(bytes.Repeat([]byte{0x01}, 20)) - remoteAddr := lnwire.DeliveryAddress(bytes.Repeat([]byte{0x02}, 20)) feeRate := chainfee.SatPerVByte(1000) @@ -948,6 +946,89 @@ func TestRbfChannelActiveTransitions(t *testing.T) { ) }) + // Even when the remote party never committed to an upfront shutdown + // script, we should still validate the delivery script they send, and + // reject one that isn't a well-formed delivery script. + name := "remote_initiated_bad_script_no_upfront_fail" + t.Run(name, func(t *testing.T) { + // The spec dropped p2pkh and p2sh for co-op closes to keep the + // dust calculations uniform, and a delivery script has to be + // something we can actually pay to, so none of these are + // acceptable even though some of them are perfectly valid + // scripts in their own right. + badScripts := []struct { + name string + script lnwire.DeliveryAddress + }{ + { + name: "empty", + script: lnwire.DeliveryAddress{}, + }, + { + name: "garbage", + script: lnwire.DeliveryAddress( + bytes.Repeat([]byte{0xff}, 5), + ), + }, + { + // Provably unspendable: paying a close output + // here would burn the remote party's balance. + name: "op_return", + script: lnwire.DeliveryAddress(append( + []byte{txscript.OP_RETURN, 32}, + bytes.Repeat([]byte{0xAB}, 32)..., + )), + }, + { + name: "bare_op_return", + script: lnwire.DeliveryAddress( + []byte{txscript.OP_RETURN}, + ), + }, + { + name: "p2pkh", + script: lnwire.DeliveryAddress(append(append( + []byte{ + txscript.OP_DUP, + txscript.OP_HASH160, 20, + }, + bytes.Repeat([]byte{0xAB}, 20)..., + ), + txscript.OP_EQUALVERIFY, + txscript.OP_CHECKSIG, + )), + }, + { + name: "p2sh", + script: lnwire.DeliveryAddress(append(append( + []byte{txscript.OP_HASH160, 20}, + bytes.Repeat([]byte{0xAB}, 20)..., + ), txscript.OP_EQUAL)), + }, + } + + for _, badScript := range badScripts { + t.Run(badScript.name, func(t *testing.T) { + // Note the config carries no remoteUpfrontAddr, + // so the only thing standing between the peer's + // script and the rest of the close flow is the + // delivery-script validation itself. + closeHarness := newCloser(t, &harnessCfg{ + localUpfrontAddr: fn.Some(localAddr), + }) + defer closeHarness.stopAndAssert() + + event := &ShutdownReceived{ + ShutdownScript: badScript.script, + } + closeHarness.sendEventAndExpectFailure( + ctx, event, ErrInvalidShutdownScript, + ) + closeHarness.assertNoStateTransitions() + }) + } + }) + // When we receive a shutdown, we should transition to the shutdown // pending state, with the local+remote shutdown addrs known. t.Run("remote_initiated_close_ok", func(t *testing.T) { @@ -1201,8 +1282,12 @@ func TestRbfShutdownPendingTransitions(t *testing.T) { // This will cause a self transition back to ShutdownPending. closeHarness.assertStateTransitions(&ShutdownPending{}) - // Next, we'll send in a shutdown complete event. - closeHarness.chanCloser.SendEvent(ctx, &ShutdownReceived{}) + // Next, we'll send in a shutdown complete event. The script is + // incidental to what this test exercises, but a shutdown always + // carries one, so we supply the remote party's. + closeHarness.chanCloser.SendEvent(ctx, &ShutdownReceived{ + ShutdownScript: remoteAddr, + }) // We should transition to the channel flushing state, then the // self event to have this state cache he early offer should diff --git a/lnwallet/chancloser/rbf_coop_transitions.go b/lnwallet/chancloser/rbf_coop_transitions.go index ac9432a5c..265caeacb 100644 --- a/lnwallet/chancloser/rbf_coop_transitions.go +++ b/lnwallet/chancloser/rbf_coop_transitions.go @@ -125,13 +125,32 @@ func validateShutdown(chanThawHeight fn.Option[uint32], return err } - // Next, we'll verify that the remote party is sending the expected - // shutdown script. - return fn.MapOption(func(addr lnwire.DeliveryAddress) error { - return validateShutdownScript( - addr, msg.ShutdownScript, &chainParams, - ) - })(upfrontAddr).UnwrapOr(nil) + // Finally, verify the remote party's delivery script. We validate it in + // all cases (mirroring the negotiation closer), rather than only when + // an upfront shutdown script is on record: passing a nil upfront script + // still runs the well-formedness check on the peer's script, and a + // non-nil upfront script additionally enforces the exact match. + return validateRemoteDeliveryScript( + upfrontAddr, msg.ShutdownScript, chainParams, + ) +} + +// validateRemoteDeliveryScript checks a delivery script the remote party sent +// us, against any upfront shutdown script we have on record for them. We end up +// paying to this script, so it has to be present, and it has to be one of the +// delivery forms we accept. An absent script is rejected here rather than +// treated as nothing to check. +func validateRemoteDeliveryScript(upfrontAddr fn.Option[lnwire.DeliveryAddress], + script lnwire.DeliveryAddress, chainParams chaincfg.Params) error { + + if len(script) == 0 { + return fmt.Errorf("%w: no delivery script", + ErrInvalidShutdownScript) + } + + return validateShutdownScript( + upfrontAddr.UnwrapOr(nil), script, &chainParams, + ) } // ProcessEvent takes a protocol event, and implements a state transition for @@ -610,8 +629,8 @@ func processNegotiateEvent(c *ClosingNegotiation, event ProtocolEvent, // updateAndValidateCloseTerms is a helper function that validates examines the // incoming event, and decide if we need to update the remote party's address, // or reject it if it doesn't include our latest address. -func (c *ClosingNegotiation) updateAndValidateCloseTerms( - event ProtocolEvent) error { +func (c *ClosingNegotiation) updateAndValidateCloseTerms(event ProtocolEvent, + env *Environment) error { assertLocalScriptMatches := func(localScriptInMsg []byte) error { if !bytes.Equal( @@ -642,9 +661,19 @@ func (c *ClosingNegotiation) updateAndValidateCloseTerms( oldRemoteAddr := c.RemoteDeliveryScript newRemoteAddr := msg.SigMsg.CloserScript - // If they're sending a new script, then we'll update to the new - // one. + // If they're sending a new script, then we'll make sure it's + // well-formed (and matches any upfront script on record) before + // we update to the new one, just as we do for the initial + // shutdown script. if !bytes.Equal(oldRemoteAddr, newRemoteAddr) { + err := validateRemoteDeliveryScript( + env.RemoteUpfrontShutdown, newRemoteAddr, + env.ChainParams, + ) + if err != nil { + return err + } + c.RemoteDeliveryScript = newRemoteAddr } @@ -695,7 +724,8 @@ func (c *ClosingNegotiation) ProcessEvent(event ProtocolEvent, // At this point, we know its a new signature message. We'll validate, // and maybe update the set of close terms based on what we receive. We // might update the remote party's address for example. - if err := c.updateAndValidateCloseTerms(event); err != nil { + err := c.updateAndValidateCloseTerms(event, env) + if err != nil { return nil, fmt.Errorf("event violates close terms: %w", err) } From 2ca05213a23a90f1501159019e51516975b911b0 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Tue, 4 Aug 2026 15:49:26 -0700 Subject: [PATCH 124/134] peer+lnwallet/chancloser: advance the legacy closer from one goroutine In this commit, we give the legacy ChanCloser a single owner, rather than letting two goroutines advance it. The peer's channelManager drives the state machine for the Shutdown and ClosingSigned messages that come off the wire, and for local close requests. The link drives it as well: while we wait for the channel to drain we register a flush hook, and the link invokes that hook from its own goroutine, where it called BeginNegotiation directly. Nothing kept the two apart, so the state field, the priorFeeOffers map, and the signing step could all be touched at once. Under `go test -race` this shows up as a data race on the state field. Rather than reach for a lock, we route the flush through the channelManager. The hook now only reports the channel ID over a new chanCloseFlushed channel, and handleChanFlushed picks it up next to the close messages. Every transition, the cached offer processing, the fee map, and the signing then happen on the one goroutine, so the closer needs no synchronization of its own. We spell that out on the type, since it's an invariant a new caller can break from the outside. The report goes out from a fresh goroutine, which matters more than it looks. The link may well be holding its own lock while it invokes the hook, and channelManager reaches for that same lock in DisableAdds, so blocking on the handoff would trade the race for a deadlock. The `go` in front of RemoveLink just above it is there for the same reason. We look the closer up with a plain map load rather than through fetchActiveChanCloser, as that one builds a fresh closer when it doesn't find an existing one, and a flush that lands after the negotiation was torn down has no business starting a new negotiation. One behavior change falls out of the move: the flush path now runs the same finalization tail as the message path. It skipped that before, so a responder that drained a cached offer would reach closeFinished and broadcast, but nothing ran finalizeChanClosure until the next close message showed up, and having already sent its final signature, there may not be one. The link == nil path already ran the tail, so this makes all three paths agree. The new test drives a close with a link that hands us the flush hook instead of running it inline, so we can check that negotiation waits on the report, and that a report for a channel we have no closer for is dropped. --- lnwallet/chancloser/chancloser.go | 6 ++ peer/brontide.go | 169 +++++++++++++++++++++++------- peer/brontide_test.go | 125 ++++++++++++++++++++++ peer/test_utils.go | 28 +++++ 4 files changed, 291 insertions(+), 37 deletions(-) diff --git a/lnwallet/chancloser/chancloser.go b/lnwallet/chancloser/chancloser.go index cc6ccffa8..60106af0d 100644 --- a/lnwallet/chancloser/chancloser.go +++ b/lnwallet/chancloser/chancloser.go @@ -162,6 +162,12 @@ type ChanCloseCfg struct { // procedure. This includes shutting down a channel, marking it ineligible for // routing HTLC's, negotiating fees with the remote party, and finally // broadcasting the fully signed closure transaction to the network. +// +// NOTE: The state machine takes no locks of its own. Nearly every method reads +// and writes the same fields, so all of them MUST be driven from a single +// goroutine. In production that's the peer's channelManager, which is the one +// place the close messages from the wire, the local close requests, and the +// link's flush notification all meet. type ChanCloser struct { // state is the current state of the state machine. state closeState diff --git a/peer/brontide.go b/peer/brontide.go index aac82005f..53c95ab08 100644 --- a/peer/brontide.go +++ b/peer/brontide.go @@ -608,6 +608,14 @@ type Brontide struct { // well as lnwire.ClosingSigned messages. chanCloseMsgs chan *closeMsg + // chanCloseFlushed carries the ID of a channel whose link has finished + // draining its HTLCs, which is the point a legacy cooperative close can + // move on to fee negotiation. The link notices this from its own + // goroutine, so it hands the channel over here rather than advance the + // closer itself, which keeps every step of the negotiation on the + // channelManager goroutine. + chanCloseFlushed chan lnwire.ChannelID + // remoteFeatures is the feature vector received from the peer during // the connection handshake. remoteFeatures *lnwire.FeatureVector @@ -686,6 +694,7 @@ func NewBrontide(cfg Config) *Brontide { localCloseChanReqs: make(chan *htlcswitch.ChanClose), linkFailures: make(chan linkFailureReport), chanCloseMsgs: make(chan *closeMsg), + chanCloseFlushed: make(chan lnwire.ChannelID), resentChanSyncMsg: make(map[lnwire.ChannelID]struct{}), startReady: make(chan struct{}), log: peerLog.WithPrefix(logPrefix), @@ -2967,6 +2976,11 @@ out: case closeMsg := <-p.chanCloseMsgs: p.handleCloseMsg(closeMsg) + // A link has finished draining the HTLCs from a channel we're + // cooperatively closing, so we can now start fee negotiation. + case cid := <-p.chanCloseFlushed: + p.handleChanFlushed(cid) + // The channel reannounce delay has elapsed, broadcast the // reenabled channel updates to the network. This should only // fire once, so we set the reenableTimeout channel to nil to @@ -4925,21 +4939,7 @@ func (p *Brontide) handleCloseMsg(msg *closeMsg) { chanCloser = c }) - handleErr := func(err error) { - err = fmt.Errorf("unable to process close msg: %w", err) - p.log.Error(err) - - // As the negotiations failed, we'll reset the channel state - // machine to ensure we act to on-chain events as normal. - chanCloser.Channel().ResetState() - if chanCloser.CloseRequest() != nil { - chanCloser.CloseRequest().Err <- err - } - - p.activeChanCloses.Delete(msg.cid) - - p.Disconnect(err) - } + handleErr := p.negotiateCloseErrHandler(msg.cid, chanCloser) // Next, we'll process the next message using the target state machine. // We'll either continue negotiation, or halt. @@ -4981,31 +4981,35 @@ func (p *Brontide) handleCloseMsg(msg *closeMsg) { }) }) - beginNegotiation := func() { - oClosingSigned, err := chanCloser.BeginNegotiation() - if err != nil { - handleErr(err) - return - } - - oClosingSigned.WhenSome(func(msg lnwire.ClosingSigned) { - p.queueMsg(&msg, nil) - }) - } - + // Without a link there's no commitment traffic left to drain, + // so the channel is already flushed as far as we're concerned. if link == nil { - beginNegotiation() - } else { - // Now we register a flush hook to advance the - // ChanCloser and possibly send out a ClosingSigned - // when the link finishes draining. - link.OnFlushedOnce(func() { - // Remove link in goroutine to prevent deadlock. - go p.cfg.Switch.RemoveLink(msg.cid) - beginNegotiation() - }) + p.beginNegotiation(chanCloser, handleErr) + + return } + // Otherwise, we register a flush hook so we hear about it once + // the link finishes draining. + link.OnFlushedOnce(func() { + // Remove link in goroutine to prevent deadlock. + go p.cfg.Switch.RemoveLink(msg.cid) + + // The link runs this hook on its own goroutine, and may + // well hold its lock while it does, so we hand the + // channel to the channelManager instead of advancing + // the closer from here. That keeps the state machine + // owned by a single goroutine, and it means we can't + // block the link on work the channelManager is doing, + // which may itself be waiting on the link's lock. + go func() { + select { + case p.chanCloseFlushed <- msg.cid: + case <-p.cg.Done(): + } + }() + }) + case *lnwire.ClosingSigned: oClosingSigned, err := chanCloser.ReceiveClosingSigned(*typed) if err != nil { @@ -5021,6 +5025,73 @@ func (p *Brontide) handleCloseMsg(msg *closeMsg) { panic("impossible closeMsg type") } + p.maybeFinalizeChanClosure(chanCloser) +} + +// handleChanFlushed is called once a link has drained the HTLCs from a channel +// we're cooperatively closing, which is our cue to move the negotiation along. +// The link notices the flush from its own goroutine and hands the channel to us +// over chanCloseFlushed, so that the closer only ever advances here. +// +// NOTE: MUST be called from the channelManager goroutine. +func (p *Brontide) handleChanFlushed(cid lnwire.ChannelID) { + // We deliberately don't go through fetchActiveChanCloser here, as that + // would build a fresh closer if the negotiation has already been torn + // down while we were waiting on the link. + chanCloserE, found := p.activeChanCloses.Load(cid) + if !found { + p.log.Debugf("ChannelID(%v) flushed, but no chan closer is "+ + "active", cid) + + return + } + + // The RBF closer drives its own flush handling, so there's nothing for + // us to do if that's the one closing this channel. + if chanCloserE.IsRight() { + return + } + + var chanCloser *chancloser.ChanCloser + chanCloserE.WhenLeft(func(c *chancloser.ChanCloser) { + chanCloser = c + }) + + p.beginNegotiation( + chanCloser, p.negotiateCloseErrHandler(cid, chanCloser), + ) +} + +// beginNegotiation starts the fee negotiation phase of a legacy cooperative +// close, sending out our opening offer if it falls to us to make one, and wraps +// the closure up if the negotiation ran all the way through to a broadcast +// transaction. +// +// NOTE: MUST be called from the channelManager goroutine. +func (p *Brontide) beginNegotiation(chanCloser *chancloser.ChanCloser, + handleErr func(error)) { + + oClosingSigned, err := chanCloser.BeginNegotiation() + if err != nil { + handleErr(err) + + return + } + + oClosingSigned.WhenSome(func(msg lnwire.ClosingSigned) { + p.queueMsg(&msg, nil) + }) + + p.maybeFinalizeChanClosure(chanCloser) +} + +// maybeFinalizeChanClosure wraps up a cooperative closure if the negotiation +// has run to completion, and does nothing if it hasn't. +// +// NOTE: MUST be called from the channelManager goroutine. +func (p *Brontide) maybeFinalizeChanClosure( + chanCloser *chancloser.ChanCloser) { + // If we haven't finished close negotiations, then we'll continue as we // can't yet finalize the closure. if _, err := chanCloser.ClosingTx(); err != nil { @@ -5033,6 +5104,30 @@ func (p *Brontide) handleCloseMsg(msg *closeMsg) { p.finalizeChanClosure(chanCloser) } +// negotiateCloseErrHandler returns the function used to tear down a legacy +// close negotiation once one of the steps we drive it through has failed. +// +// NOTE: MUST be called from the channelManager goroutine. +func (p *Brontide) negotiateCloseErrHandler(cid lnwire.ChannelID, + chanCloser *chancloser.ChanCloser) func(error) { + + return func(err error) { + err = fmt.Errorf("unable to process close msg: %w", err) + p.log.Error(err) + + // As the negotiations failed, we'll reset the channel state + // machine to ensure we act to on-chain events as normal. + chanCloser.Channel().ResetState() + if chanCloser.CloseRequest() != nil { + chanCloser.CloseRequest().Err <- err + } + + p.activeChanCloses.Delete(cid) + + p.Disconnect(err) + } +} + // HandleLocalCloseChanReqs accepts a *htlcswitch.ChanClose and passes it onto // the channelManager goroutine, which will shut down the link and possibly // close the channel. diff --git a/peer/brontide_test.go b/peer/brontide_test.go index 3d8023b1a..ecc3d68a5 100644 --- a/peer/brontide_test.go +++ b/peer/brontide_test.go @@ -175,6 +175,131 @@ func TestPeerChannelClosureAcceptFeeResponder(t *testing.T) { notifier.ConfChan <- &chainntnfs.TxConfirmation{} } +// TestPeerChannelClosureFlushDrivesNegotiation checks that a legacy cooperative +// close holds off on fee negotiation until the link reports that the channel +// has drained, and that the report is what carries the negotiation forward. The +// link notices the flush on its own goroutine, so it hands the channel to the +// channelManager rather than advancing the closer itself. +func TestPeerChannelClosureFlushDrivesNegotiation(t *testing.T) { + t.Parallel() + + harness, err := createTestPeerWithChannel(t, noUpdate) + require.NoError(t, err, "unable to create test channels") + + var ( + alicePeer = harness.peer + bobChan = harness.channel + mockSwitch = harness.mockSwitch + broadcastTxChan = harness.publishTx + notifier = harness.notifier + ) + + chanPoint := bobChan.ChannelPoint() + chanID := lnwire.NewChanIDFromOutPoint(chanPoint) + + // The link holds on to the flush hook rather than running it inline, so + // we get to say when the channel looks drained. + mockLink := newDeferredFlushUpdateHandler(chanID) + mockSwitch.links = append(mockSwitch.links, mockLink) + + dummyDeliveryScript := genScript(t, p2wshAddress) + + // We send a shutdown request to Alice, and expect her own Shutdown in + // response. + alicePeer.chanCloseMsgs <- &closeMsg{ + cid: chanID, + msg: lnwire.NewShutdown(chanID, dummyDeliveryScript), + } + + var msg lnwire.Message + select { + case outMsg := <-alicePeer.outgoingQueue: + msg = outMsg.msg + case <-time.After(timeout): + t.Fatalf("did not receive shutdown message") + } + + shutdownMsg, ok := msg.(*lnwire.Shutdown) + require.True(t, ok, "expected Shutdown message, got %T", msg) + + respDeliveryScript := shutdownMsg.Address + + // The channel hasn't drained yet, so Alice shouldn't have opened fee + // negotiation, even though she's the one that funded the channel. + select { + case outMsg := <-alicePeer.outgoingQueue: + t.Fatalf("negotiation started before the channel flushed: %T", + outMsg.msg) + + case <-time.After(shortTimeout): + } + + // A flush report for a channel we have no closer for should be dropped + // on the floor rather than start anything. + var unknownChanID lnwire.ChannelID + select { + case alicePeer.chanCloseFlushed <- unknownChanID: + case <-time.After(timeout): + t.Fatalf("channelManager not reading flush reports") + } + + // Now we let the link report the flush, which is what should carry the + // negotiation into its fee phase. + select { + case hook := <-mockLink.flushHooks: + go hook() + case <-time.After(timeout): + t.Fatalf("no flush hook was registered") + } + + select { + case outMsg := <-alicePeer.outgoingQueue: + msg = outMsg.msg + case <-time.After(timeout): + t.Fatalf("did not receive ClosingSigned message") + } + + respClosingSigned, ok := msg.(*lnwire.ClosingSigned) + require.True(t, ok, "expected ClosingSigned message, got %T", msg) + + // We accept the fee, and send a ClosingSigned with the same fee back so + // she knows we agreed. + aliceFee := respClosingSigned.FeeSatoshis + bobSig, _, _, err := bobChan.CreateCloseProposal( + aliceFee, dummyDeliveryScript, respDeliveryScript, + ) + require.NoError(t, err, "error creating close proposal") + + parsedSig, err := lnwire.NewSigFromSignature(bobSig) + require.NoError(t, err, "error parsing signature") + + alicePeer.chanCloseMsgs <- &closeMsg{ + cid: chanID, + msg: lnwire.NewClosingSigned(chanID, aliceFee, parsedSig), + } + + // Alice should now see that we agreed on the fee, and broadcast the + // closing transaction. + select { + case <-broadcastTxChan: + case <-time.After(timeout): + t.Fatalf("closing tx not broadcast") + } + + // Need to pull the remaining message off of Alice's outgoing queue. + select { + case outMsg := <-alicePeer.outgoingQueue: + msg = outMsg.msg + case <-time.After(timeout): + t.Fatalf("did not receive ClosingSigned message") + } + _, ok = msg.(*lnwire.ClosingSigned) + require.True(t, ok, "expected ClosingSigned message, got %T", msg) + + // Alice should be waiting in a goroutine for a confirmation. + notifier.ConfChan <- &chainntnfs.TxConfirmation{} +} + // TestPeerChannelClosureAcceptFeeInitiator tests the shutdown initiator's // behavior if we can agree on the fee immediately. func TestPeerChannelClosureAcceptFeeInitiator(t *testing.T) { diff --git a/peer/test_utils.go b/peer/test_utils.go index 673eceed8..83667db30 100644 --- a/peer/test_utils.go +++ b/peer/test_utils.go @@ -43,6 +43,10 @@ const ( // a return value on a channel. timeout = time.Second * 5 + // shortTimeout is the window a test waits for when it expects nothing + // to show up on a channel. + shortTimeout = time.Millisecond * 250 + // testCltvRejectDelta is the minimum delta between expiry and current // height below which htlcs are rejected. testCltvRejectDelta = 13 @@ -388,6 +392,12 @@ type mockUpdateHandler struct { cid lnwire.ChannelID isOutgoingAddBlocked atomic.Bool isIncomingAddBlocked atomic.Bool + + // flushHooks receives the hooks registered through OnFlushedOnce when + // the handler was built with deferFlush set. Tests that want to control + // when the channel looks flushed read the hook from here and call it + // themselves, standing in for the link's own goroutine. + flushHooks chan func() } // newMockUpdateHandler creates a new mockUpdateHandler. @@ -397,6 +407,18 @@ func newMockUpdateHandler(cid lnwire.ChannelID) *mockUpdateHandler { } } +// newDeferredFlushUpdateHandler creates a mock link that holds on to the hooks +// registered through OnFlushedOnce instead of running them inline, so a test +// can decide when the channel becomes flushed. +func newDeferredFlushUpdateHandler( + cid lnwire.ChannelID) *mockUpdateHandler { + + return &mockUpdateHandler{ + cid: cid, + flushHooks: make(chan func(), 1), + } +} + // HandleChannelUpdate currently does nothing. func (m *mockUpdateHandler) HandleChannelUpdate(msg lnwire.Message) {} @@ -465,6 +487,12 @@ func (m *mockUpdateHandler) IsFlushing(dir htlcswitch.LinkDirection) bool { } func (m *mockUpdateHandler) OnFlushedOnce(hook func()) { + if m.flushHooks != nil { + m.flushHooks <- hook + + return + } + hook() } func (m *mockUpdateHandler) OnCommitOnce( From 84ddced2b5c1bfbbd0ad4731f636c2c02327a300 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Tue, 4 Aug 2026 15:49:26 -0700 Subject: [PATCH 125/134] lnwallet/chancloser: record the remote close output only when accepted In this commit, we hold off on recording the remote party's close output until we've decided we can act on their Shutdown. ReceiveShutdown wrote the field before it looked at the state, so a Shutdown that arrives at a point where we have nothing to do with it, say once we've already finished the negotiation, would still overwrite the output we settled on before being turned away with ErrInvalidState. The output we report for the close then describes a message we rejected. Nothing acts on this today, as we hand the outputs to the caller only after ClosingTx tells it the negotiation finished, but the field is what we report to the party that asked for the close, so we may as well only fill it in from a message we accepted. --- lnwallet/chancloser/chancloser.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lnwallet/chancloser/chancloser.go b/lnwallet/chancloser/chancloser.go index 60106af0d..a95dbac0f 100644 --- a/lnwallet/chancloser/chancloser.go +++ b/lnwallet/chancloser/chancloser.go @@ -593,10 +593,13 @@ func (c *ChanCloser) ReceiveShutdown(msg lnwire.Shutdown) ( noShutdown := fn.None[lnwire.Shutdown]() // We'll track their remote close output, even if it's dust in BTC - // terms, it might still carry value in custom channel terms. + // terms, it might still carry value in custom channel terms. We only + // commit it to our state in the branches below that go on to accept the + // message: a Shutdown that shows up at a point where we can't act on it + // has no business overwriting an output we already settled on. _, dustAmt := c.cfg.Channel.RemoteBalanceDust() _, remoteBalance := c.cfg.Channel.CommitBalances() - c.remoteCloseOutput = fn.Some(CloseOutput{ + remoteCloseOutput := fn.Some(CloseOutput{ Amt: remoteBalance, DustLimit: dustAmt, PkScript: msg.Address, @@ -643,6 +646,7 @@ func (c *ChanCloser) ReceiveShutdown(msg lnwire.Shutdown) ( // address. We'll use this when we craft the closure // transaction. c.remoteDeliveryScript = msg.Address + c.remoteCloseOutput = remoteCloseOutput // We'll generate a shutdown message of our own to send across // the wire. @@ -692,6 +696,7 @@ func (c *ChanCloser) ReceiveShutdown(msg lnwire.Shutdown) ( // address, we'll record their preferred delivery closing // script. c.remoteDeliveryScript = msg.Address + c.remoteCloseOutput = remoteCloseOutput // At this point, we can now start the fee negotiation state, by // constructing and sending our initial signature for what we From 23257e7253b7c9f78daff011e21c6997665d8f91 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Wed, 5 Aug 2026 23:27:24 -0300 Subject: [PATCH 126/134] docs: add release notes entry for the coop close fixes Backport of the release note from PR #11019, retargeted from release-notes-0.21.2.md to release-notes-0.20.2.md. --- docs/release-notes/release-notes-0.20.2.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/release-notes/release-notes-0.20.2.md b/docs/release-notes/release-notes-0.20.2.md index 2c39d95ca..555783a0a 100644 --- a/docs/release-notes/release-notes-0.20.2.md +++ b/docs/release-notes/release-notes-0.20.2.md @@ -56,6 +56,16 @@ to timeout resolution, allowing the sweeper to continue using an expiry-aware confirmation target. +* [Fixed a data race](https://github.com/lightningnetwork/lnd/pull/11019) in the + legacy cooperative close state machine, which was advanced from both the link + goroutine and the peer goroutine with nothing synchronizing the two. The link + now reports a flushed channel to the peer's channel manager instead of driving + the closer itself, so every step of a close runs on a single goroutine. The + same change has the RBF closer validate the remote party's delivery script in + all cases, rather than only when an upfront shutdown script was on record for + that peer, and rejects an absent script instead of treating it as nothing to + check. + # New Features ## Functional Enhancements From 1551b44d6df61c8df49024321800606a539d36ef Mon Sep 17 00:00:00 2001 From: ziggie Date: Fri, 7 Aug 2026 09:49:14 -0300 Subject: [PATCH 127/134] docs: move post-0.20.2 entries into new 0.20.3 release notes The v0.20.2-beta tag was cut on 2026-07-07 and published as a release the following day, but the seven backports that landed on the release branch afterwards all appended their entries to release-notes-0.20.2.md, since no notes file existed for the next patch release. The published 0.20.2 notes therefore advertised fixes that are not in the 0.20.2 binaries. Create release-notes-0.20.3.md and move those entries into it, restoring release-notes-0.20.2.md to its content at the v0.20.2-beta tag. The entry prose is carried over verbatim and matches the wording already used for the same backports in release-notes-0.21.2.md on the v0.21.x branch. --- docs/release-notes/release-notes-0.20.2.md | 55 ---------- docs/release-notes/release-notes-0.20.3.md | 116 +++++++++++++++++++++ 2 files changed, 116 insertions(+), 55 deletions(-) create mode 100644 docs/release-notes/release-notes-0.20.3.md diff --git a/docs/release-notes/release-notes-0.20.2.md b/docs/release-notes/release-notes-0.20.2.md index a98851288..f7463c95f 100644 --- a/docs/release-notes/release-notes-0.20.2.md +++ b/docs/release-notes/release-notes-0.20.2.md @@ -36,46 +36,12 @@ distinguish off-chain auto-fail heights from on-chain settlement deadlines, or `AutoFailHeight()` if they only need the legacy flattened value. -* [Bounded the memory used while syncing the channel - graph](https://github.com/lightningnetwork/lnd/pull/10992). A peer replying - to our `query_channel_range` could previously make us buffer an - unpredictable number of short channel IDs, as the only limit was a coarse - 67MB cap on the bytes a single zlib-compressed reply could decompress to. - Replies are now capped at a precise number of short channel IDs, both - per-message and in aggregate across a single query, and the accumulated - reply state is released as soon as any reply fails validation so that a - peer cannot pin it by deliberately forcing an error. - -* [Refined invoice update - handling](https://github.com/lightningnetwork/lnd/pull/11024) across MPP, AMP, - and legacy payment paths, including keysend records and preimage-dependent - settlement outcomes. - -* Outgoing contest resolvers now [retain the corresponding incoming HTLC - expiry](https://github.com/lightningnetwork/lnd/pull/11032) when transitioning - to timeout resolution, allowing the sweeper to continue using an - expiry-aware confirmation target. - -* [Fixed a data race](https://github.com/lightningnetwork/lnd/pull/11019) in the - legacy cooperative close state machine, which was advanced from both the link - goroutine and the peer goroutine with nothing synchronizing the two. The link - now reports a flushed channel to the peer's channel manager instead of driving - the closer itself, so every step of a close runs on a single goroutine. The - same change has the RBF closer validate the remote party's delivery script in - all cases, rather than only when an upfront shutdown script was on record for - that peer, and rejects an absent script instead of treating it as nothing to - check. - # New Features ## Functional Enhancements ## RPC Additions -* The [HTLC interceptor](https://github.com/lightningnetwork/lnd/pull/10942) now - exposes the next hop of a blinded route that identifies it by node ID - (`next_node_id`) rather than by channel. - ## lncli Additions # Improvements @@ -92,21 +58,8 @@ will now have its `UpdateChannelPolicy` request rejected, and must lower the value accordingly below the specified maximum. -* [The HTLC forward interceptor now validates](https://github.com/lightningnetwork/lnd/pull/11028) - that derived auto-fail heights are within the supported range before they are - exposed through the interceptor API. - ## RPC Updates -* `ForwardHtlcInterceptRequest.outgoing_requested_chan_id` now holds a reserved - sentinel value (`18446744073709551615`, all bits set) when the - [HTLC interceptor](https://github.com/lightningnetwork/lnd/pull/10942) reports - a blinded forward that identifies the next hop by node ID. The sender of such - a forward requests no channel, so a zero value here would make a client that - detects the exit hop by a zero channel ID classify the forward as a final - receive. Clients that switch on this field must handle the sentinel and read - `outgoing_requested_node_id` for the next hop. - ## lncli Updates ## Breaking Changes @@ -118,13 +71,6 @@ # Technical and Architectural Updates ## BOLT Spec Updates -* [Fixed an issue](https://github.com/lightningnetwork/lnd/pull/10942) where an - lnd node acting as a relaying node (including the introduction node) in a - blinded path failed to forward the payment when the next hop was identified by - node ID (`next_node_id`) rather than a short channel ID. The next hop's public - key is now resolved to one of our channels with that peer using non-strict - forwarding. - ## Testing ## Database @@ -136,5 +82,4 @@ # Contributors (Alphabetical Order) * Erick Cestari -* Olaoluwa Osuntokun * Ziggie diff --git a/docs/release-notes/release-notes-0.20.3.md b/docs/release-notes/release-notes-0.20.3.md new file mode 100644 index 000000000..82ed80798 --- /dev/null +++ b/docs/release-notes/release-notes-0.20.3.md @@ -0,0 +1,116 @@ +# Release Notes +- [Bug Fixes](#bug-fixes) +- [New Features](#new-features) + - [Functional Enhancements](#functional-enhancements) + - [RPC Additions](#rpc-additions) + - [lncli Additions](#lncli-additions) +- [Improvements](#improvements) + - [Functional Updates](#functional-updates) + - [RPC Updates](#rpc-updates) + - [lncli Updates](#lncli-updates) + - [Breaking Changes](#breaking-changes) + - [Performance Improvements](#performance-improvements) + - [Deprecations](#deprecations) +- [Technical and Architectural Updates](#technical-and-architectural-updates) + - [BOLT Spec Updates](#bolt-spec-updates) + - [Testing](#testing) + - [Database](#database) + - [Code Health](#code-health) + - [Tooling and Documentation](#tooling-and-documentation) +- [Contributors (Alphabetical Order)](#contributors-alphabetical-order) + +# Bug Fixes + +* [Bounded the memory used while syncing the channel + graph](https://github.com/lightningnetwork/lnd/pull/10992). A peer replying + to our `query_channel_range` could previously make us buffer an + unpredictable number of short channel IDs, as the only limit was a coarse + 67MB cap on the bytes a single zlib-compressed reply could decompress to. + Replies are now capped at a precise number of short channel IDs, both + per-message and in aggregate across a single query, and the accumulated + reply state is released as soon as any reply fails validation so that a + peer cannot pin it by deliberately forcing an error. + +* [Refined invoice update + handling](https://github.com/lightningnetwork/lnd/pull/11024) across MPP, AMP, + and legacy payment paths, including keysend records and preimage-dependent + settlement outcomes. + +* [Fixed a data race](https://github.com/lightningnetwork/lnd/pull/11019) in the + legacy cooperative close state machine, which was advanced from both the link + goroutine and the peer goroutine with nothing synchronizing the two. The link + now reports a flushed channel to the peer's channel manager instead of driving + the closer itself, so every step of a close runs on a single goroutine. The + same change has the RBF closer validate the remote party's delivery script in + all cases, rather than only when an upfront shutdown script was on record for + that peer, and rejects an absent script instead of treating it as nothing to + check. + +* Outgoing contest resolvers now [retain the corresponding incoming HTLC + expiry](https://github.com/lightningnetwork/lnd/pull/11032) when transitioning + to timeout resolution, allowing the sweeper to continue using an + expiry-aware confirmation target. + +# New Features + +## Functional Enhancements + +## RPC Additions + +* The [HTLC interceptor](https://github.com/lightningnetwork/lnd/pull/10942) now + exposes the next hop of a blinded route that identifies it by node ID + (`next_node_id`) rather than by channel. + +## lncli Additions + +# Improvements + +## Functional Updates + +* [The HTLC forward interceptor now validates](https://github.com/lightningnetwork/lnd/pull/11028) + that derived auto-fail heights are within the supported range before they are + exposed through the interceptor API. + +## RPC Updates + +* `ForwardHtlcInterceptRequest.outgoing_requested_chan_id` now holds a reserved + sentinel value (`18446744073709551615`, all bits set) when the + [HTLC interceptor](https://github.com/lightningnetwork/lnd/pull/10942) reports + a blinded forward that identifies the next hop by node ID. The sender of such + a forward requests no channel, so a zero value here would make a client that + detects the exit hop by a zero channel ID classify the forward as a final + receive. Clients that switch on this field must handle the sentinel and read + `outgoing_requested_node_id` for the next hop. + +## lncli Updates + +## Breaking Changes + +## Performance Improvements + +## Deprecations + +# Technical and Architectural Updates + +## BOLT Spec Updates + +* [Fixed an issue](https://github.com/lightningnetwork/lnd/pull/10942) where an + lnd node acting as a relaying node (including the introduction node) in a + blinded path failed to forward the payment when the next hop was identified by + node ID (`next_node_id`) rather than a short channel ID. The next hop's public + key is now resolved to one of our channels with that peer using non-strict + forwarding. + +## Testing + +## Database + +## Code Health + +## Tooling and Documentation + +# Contributors (Alphabetical Order) + +* bitromortac +* Olaoluwa Osuntokun +* Ziggie From ae1c701c562fd8414e2e25db567f47dc51ccd51d Mon Sep 17 00:00:00 2001 From: ziggie Date: Fri, 7 Aug 2026 09:43:02 -0300 Subject: [PATCH 128/134] build: bump version to v0.20.3-beta.rc1 --- build/version.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build/version.go b/build/version.go index 0d7b69633..14d1a16bc 100644 --- a/build/version.go +++ b/build/version.go @@ -47,11 +47,11 @@ const ( AppMinor uint = 20 // AppPatch defines the application patch for this binary. - AppPatch uint = 02 + AppPatch uint = 03 // AppPreRelease MUST only contain characters from semanticAlphabet per // the semantic versioning spec. - AppPreRelease = "beta" + AppPreRelease = "beta.rc1" ) func init() { From 1a8cf49dfd2de1d1ec7fc5f5dcdb6244d9b780cb Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Tue, 17 Feb 2026 12:21:36 +0100 Subject: [PATCH 129/134] scripts: add gpg key for georgetsagk (cherry picked from commit 05d1df6de1e9c984826270fa368dac573cdc96f4) --- scripts/keys/georgetsagk.asc | 52 ++++++++++++++++++++++++++++++++++++ scripts/verify-install.sh | 1 + 2 files changed, 53 insertions(+) create mode 100644 scripts/keys/georgetsagk.asc diff --git a/scripts/keys/georgetsagk.asc b/scripts/keys/georgetsagk.asc new file mode 100644 index 000000000..803123bee --- /dev/null +++ b/scripts/keys/georgetsagk.asc @@ -0,0 +1,52 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGLF2RABEADTlKM5TvtsicbWF4WXjX/XHs/TRkp8RWXdNqkMWoIP27nWy24v +FEL6dU3FNnaPzeHfLS6+SVoOM2ku5X/KdIZoaiejXEN8WBuXz8Ydo05PKMormXDP +PUDjxdUsO5qrY/1DNQM4+9hKq1f8PrEj95DS6CPp8jlsei1W2BqaSATZNhfgu3Yb +ruQOlrz4nC1A1WmD5T/YrGWoGbJjziziVgbtLzC0P8cts7Za0cmH99ohkOodPq1N ++vg0J2Dto9S2qBsaNuSD5Vy9jQ1FQFXHE2Z3wPWLJJeHo0Ea5ewdpkWrm8QlIu6A +RyjGSBkoToOSSez4FPsublthau9ETIsOJv4c9+sJx+tsqP2UazZt4zLucpsMAXBW +RH9lCpvSK+cMdrTRgD6J3K1gtFToxnPHpVuOQn7tMowGEzSPkEO5zLeXvIm3rsKI +R9jmpZ0JOn4V5+6HrERyhnffcwRvx8Ce3+btPmHHYfVBD2e3PIODbOfC3Ppw6AXg +Pfkwr3tUt3JRzqQBhcX3wMB1kFS3G9/6l9IqhK9rFzlEu4bBEIcCLq/ljX012hXj +EjKOQ372Qo2sVRuerg247RU3RQzw4wsNgecIIgawPo/dzR14EW+K15CIYTuH/MNP +4hS605Wvdx6ZPzCPm4hBXAgGNK9j2UsxSQKqpxmAqdquF1xgZFrntUwN5QARAQAB +tDFHZW9yZ2UgVHNhZ2thcmVsaXMgPGdlb3JnZS50c2Fna2FyZWxpc0BnbWFpbC5j +b20+iQJOBBMBCgA4FiEEFYO2AbtXzHzS34qH4I3qmxK2avYFAmLF2RACGwMFCwkI +BwIGFQoJCAsCBBYCAwECHgECF4AACgkQ4I3qmxK2avZqwg/9FeJVFGtGBYx5aQIC +s+chEIx/bWM8oSxy8ruUkmHbK3tUkmzhnYgXdD+mCoN8MFWEGROEOyFip91Ay5v/ +MG1QEI1FgBiaTVODVFgDMTOfuIWq2A45m0QPK6JS0sTkxk9qeekUeyMLjcXaibLU +sGfEshGxszjWakZjDtEGbRYygWlPTX73faKCeqVxr9hF9OLBC+Ava75yhnm00GI9 +BT9udpkYxeFmFqDAgf/V84KdBV5cMWIAp2/FXl7GFA8phX9i3SfAO3TDSXfxQBQr +t39rjEQNyc58JG8QTxbgeFaAexykvvUDIjv/jhBSZivzcUAeRn4k+GscMmZEQ7Hw +tEoEgwKcHsqOwJ3CyCOEEWy9ZN0kVzxCsMLkbWZoQjeFLmIuVaNGkGWjBQJBH4kQ +WGRLd5d6PIDvHGh2rKIIS4SFL5nZHG1HQocKV001BFZeGt97nNKqDyqxqvp/7TEJ +Qx0waJOkGayvWT5NGZgGGrMMyBd8jffpzgR8YXI8VgTWJNWlLV4Ousl4p7H7iAQ3 +cODlECqzl376fzv3OAnK31DG3eWCsGairu+upxjugoImXm5QQpWEORYCR57H038i +v/gAFMIlZLnTS0Dgy0shQQb+Ygr7lqAPkKf3WGfbrt0gxlmXdo6oOoMs6T5RIFDM +/oigTX6Rla5W3cusBWEL1Gcp12e5Ag0EYsXZEAEQAMmqb9GgFe4PjEVexPh52361 +bJOSv82komNbXoWpGb45lbDFTZID1cTmi5q26AQkP+apkNcfnVTu1cQ4b/uUHj11 +AfSbn5XoYAKx4C/0TaZzSmWHuex6HkPc8eEr2ITyBZw90Z8RD5RnGFntjNsP+5EP ++wXzGTNnIXbP+arMcROv+1Ie7qAkqTXvMAAwFueG/jxJTA+JVvUEriubTcMAWjpy +5EQKF+NNiUtCdxWxwQvVdnQlUXDYWhux0IECpRXl9VKg/Arcx2vNYz1Q+TX9kPCZ +5orLfwyXg7Criw5GLHSCpqghLO8VdRuXulpDp58AIuM/+RouMkVhFTNBq69qyB08 +Kh7xM4C6PrmasLVK16fED5AWuW6VKTv4c0CxvJ5XjxpQuXsB+JHQ96Zk97wzDfcN +rBVTHXsMVQLSibExaU9PgHIZ0nw1Ipf1TA7R+t+iPxrgz9l8gtO2ddgEbpKNeQ37 +IPxx5GKsWIIi9hIJdIJZQVTLikRl6sQLQoFLwKLGu1UtOBDQKw4RyogKTmHcRFKt +ZdYUfwdhE1p9mUMxYZwloetBpG2qs7y1bzBbYNOaGECoRiSH5c1amFAnrLARXJAe +xRkgXNKwyBSIH8rizCahHQOFOvmyXyhPb2EuYPzbz8beCJXp/ALKzKSHpqcVuxHc +j/09NxZW7UtR4Jl758yZABEBAAGJAjYEGAEKACAWIQQVg7YBu1fMfNLfiofgjeqb +ErZq9gUCYsXZEAIbDAAKCRDgjeqbErZq9vLpD/4xRTqXfGfoiuJV4CH3EYvvKNFJ +Na1hSBXfmsCCoQ0kAyvFl+5gcmLvFTaII7Kt34lZsVWX0XCWgw6+ofGAZcekcXRR +swkOUMlmr56/94OrBAR0tG10KOyV3VrVY2n/4VYIymEdcMqQgwCSZ58XagOdYpBs +k/+limgo325G42LDec7VzR69UGG20QCJ9D3z2z+q5Ogg+tu9/QbzsEmzkmpc7huw +NFqIJ8TTSSramr+qYhkk9Eh7q0fSlmAuNKGmXvoQzKqAnGbluiFD8lf73G2Xz4Zv +zk+4AmU3Z70MIWrT+GgDaW5rT0FXrn9zOVafp2y2RqbFAaKSHucb42Qg1h3sgPqS +Kg/VVir7vECEpSB7ei37d1bTPe29Z6aGyFZeNuZ9J6MwY1fLsbbHwEY1RrGaiD8c +7gzBiVZl2/Nmuo6JgjnsoQDbs493MIud7dxMTb0ffsM28L0kwrD8EWHSIAY+eTBC +tNHS+Np24yd0mDNneYvR3+VIkjMWlkpoTqCC1vcCO7qB1sIBE/OEh5bNjjAlYPB2 +v5SKXC65iyoaMUO7IuTJsN+7jd7lKgqhl4OKnj48UQwXUzY9Yrk0OvttcZzXpls/ +OtzViiMG5c1Pacbuqz/PCc1D9AOudOyCYT0fSfsxrAB4GVmKr8kCVWp8BQDktLzk +Zib+Ntgdwfvr/xKO+A== +=RMmr +-----END PGP PUBLIC KEY BLOCK----- diff --git a/scripts/verify-install.sh b/scripts/verify-install.sh index a1f84eded..d98264fd5 100755 --- a/scripts/verify-install.sh +++ b/scripts/verify-install.sh @@ -32,6 +32,7 @@ KEYS+=("5295A477FFC8064D7057B191FA7E65C951F12439 proofofkeags") KEYS+=("3E9BD4436C288039CA827A9200C9E2BC2E45666F suheb") KEYS+=("5F75437E11695F86D50C11BB1AFF9C4DCED6D666 ziggie1984") KEYS+=("C20A78516A0944900EBFCA29961CC8259AE675D4 ViktorT-11") +KEYS+=("1583B601BB57CC7CD2DF8A87E08DEA9B12B66AF6 georgetsagk") TEMP_DIR=$(mktemp -d /tmp/lnd-sig-verification-XXXXXX) From b86fcc0873fb2a61d27eacde0276ac6d5bf4b47f Mon Sep 17 00:00:00 2001 From: Gijs van Dam Date: Mon, 29 Jun 2026 21:40:17 +0200 Subject: [PATCH 130/134] scripts/keys: add pub key for gijswijs --- scripts/keys/gijswijs.asc | 51 +++++++++++++++++++++++++++++++++++++++ scripts/verify-install.sh | 1 + 2 files changed, 52 insertions(+) create mode 100644 scripts/keys/gijswijs.asc diff --git a/scripts/keys/gijswijs.asc b/scripts/keys/gijswijs.asc new file mode 100644 index 000000000..485470bfd --- /dev/null +++ b/scripts/keys/gijswijs.asc @@ -0,0 +1,51 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGXxd6kBEACX3rx9+yFbnStdm6Jax+7rfy6fizMSn709h1SXzqFGJOvFux4V +O3wO1xnnSSh0cP9AKIp6ntbppBhlq1J8VmRUu/hi73uyS7i6x9a45WT03vCEil2a +duJp6Aij5RltAxmBmJcoFa5bcUpj8ZDLxnQsF/A6V8HFQ1ijZfs5GLNVNw9sOeFT +CHK5NRFVaE2bBAj6npVJK/taGntSQzCAcD8RXHQTIVxHy62tCt5pSeQzEvJsAg5c +5uFntw/cutwSHBYpeiBeSxtUpAOl8aIEF2/xuxosymImXzyiKYxaD/LCoaw/mjjR +FAHHES33Mzkg1AS04bLuEQ0LxYEd5pcHrd41+DV2NMJ4TFISh5ecP4SHRjRjYKrO +BO+Lx0J1seg2BLbJoXa5pToJwl329yYZUayu24GENe6sNiYEDCs3cSURKKZo4olX +n0g3MdsE9GYUIpZQPQPNjWYr8ExcD5DxUahG1WBRhQDKaDDDsLjaWyX5l4cpBDtA +R4KTzLZXxeV1vxLsIY1RlF6T7MbGfJPFRluDUOUWVlypbvSYVD9PR5rpUDZBz13g +3ncqO4bdi7b9Yg4YFTfqqVz4RZAJKGSbmHedjBFiJNkNQg/5pz6LisFlpiCG3d0X +3Hdo6X/5tRXncdX2E5rZBf6gGYsI/Qr6PyF+CEohNOWYpjrBcGaxys57/QARAQAB +tClHaWpzIHZhbiBEYW0gPGdpanNAbGlnaHRuaW5nLmVuZ2luZWVyaW5nPokCOAQT +AQgALAUCZfF3qQkQAZpEhXc1/SACGwMFCR4TOAACGQEECwcJAwUVCAoCAwQWAAEC +AACYAA/+JAjIWpV1uCnE8/27ceec/8ZVoXqi6hjUny7itqnQa7de5Y4jkDDZTNBh +epHRSf0/mJmEtmqZtjON6HmBxex4LdvacqeWeVQPcohikr5ZkkuYL+QDAJutImjq +LqJA2u3nZN9u50rEHVcF2TD7X939I49WyCgdcPs2HKYODPkcBbn1Riw8Zz6BBsQW +mONXhPMGprrZulrKM/KAVwwvBUf0krnRTRi4X/n7MfXssDjHmv/LVeDmRR+6vPKv +ri+aiIFwd2mtZnx6mFS9DvMQNwOmabOxT8LPHB6+FKA9o4R/hl7wxKFNVYgtOpxv +14Sux5w7oBLRXzoxWmaT55hKEQimQEdYl/0y8TF2QNV4XEB7l9H4mTaul3T9z9fV +mYZOxXSOIywn9xu51K58KLDhyZLlP45nfgFFbgxDl3bLoneD4b8S5uTkUh9yCDZs +ufpAcGEL+17fuPX6k5KhuldMkXk++dot++NyqEfzwD9op2OBckHzhuAdKg67Y99m +ZKHNa62dI4S9ma6IHhYdVtrp4xEQtHeHZeALRwSgzNhEbU7zxcMiaBLNLKeaLjXa +usX5cKSeW7wPHl8g5+SQrDvW3ZyohMwZoO+RBE8hsier/d+wsX6NSHacbtbys/6y +Em8w4mouEa487wzAWRSO/brmr0txaUuWHQasMZ1DWTOFeP2nw8K5Ag0EZfF3qQEQ +AMPIbPjPkVcN3Dxs9yJ4B4v9VI9H2Rd/o93f1C9hhULqxX8Y2lOBXU6CPdAUTElC +baBvu16/w90hnwFjNdxP6n3mVJ4LDgF9Xo+MyvHDmJsHL3SwwNdY96UCino0l5Vd +v8kUKEWFttmvZRXDjlo8Tpu0b8pGnUATJGQasPI4YQ7qCrr48JGo81SVVf6IJol5 +3svU1z/fsDJ242EdXilWQHKNjnBJd7VZ+DtWaJjv16Zs905XKVxH7+zsyavofyLE +W1Sv0e78uSHyzXyLuIY/4OdT6LJJ9QuwKYfp4+S4SID/Def7JBMdSh0h9baG0Wzu +7hVZ6zhnJe3V0LcUcVhoRys58xF5Wpfu+8U6pz2HPN+CJsAD3XHaXBMJjaWIbZ/O +06OtjI8Y/5870YjjiPAUXo5FpVFBPTX0fft3LoBXIagS3ZOswk0j3TXwf4JgCGAW +PpIaBQ0C/ZjrOm79B020ArziCEyiiKOXre9vK4pGqz07CQfu/JxrdUdsKlnPEoPF +z2aYkNaeqa2ivONGvDbBPYIEk+LgbGfV1wtpA1FKHo3Hv66WD5qlOQX+bt9o2lHL +gZGgngjGxzq0p/VIy0hdLSKTvVfVioNofj7Vvz756k2q/d3saZSGaVzHEJ8lRaOo +hMVs8/zUuRKx6Ow2KLqKVHl3vt4lLoX74xN1zVXpUyYHABEBAAGJAjUEGAEIACkF +AmXxd6kJEAGaRIV3Nf0gAhsMBQkeEzgABAsHCQMFFQgKAgMEFgABAgAAquYP/3Xd +lJ908yJFzuBpVl05MBPGDzTiQNMGt8LDrSdmvqxgtj7+KaXDPbH3wW8GHI3GaweQ +bhHuMrty2vX5CDuK/hdvwRhZ1WBaGryPtz5rsODhMvqiiGMGBKfSYRdc2thK1L4e +T4UQa1Kbd5odszwA0Og1y483jjduqq7otJ1MsfzCOhSc6vEzZzaKjHJJPfhIt62U +4EDhZGGyZ10YiFNsdWc2twJu4ma8a2TxTQLZIPlH61BHuHfZOjf4s9wzoJBjOjPO +LmIfVfezgJI6rSM30Gr3lnchTd4sg25GUNxEyMrKapo/ztABe+coOzfqy8Kg0sQV +6s3QvTAOHrEh8oClWX+dY5j00j+pfkdi6gfPT7VEZK4Hqko8UzUy9armBPmdHfcv +Rx6HoUIZEGfYFgKOdSBoE/Q+4f7hiD5xpHx7FNMQTzI4x9zsMp+8CO4+YZHVSSBA +r5j4x3Drf86+ZuVmnho4qpHPB2jyBQ6eulYESJE/GWEzuwox7zF1Qe8PPSCkwTux +UeGCcuxiTGfsCy0dWy+4/1BzF6DkuCxVwPqcQ4r4wxRHWs3Qxii3Cw9wep4Txl1+ +DQD+EtphnXs6LSk3fxI0E50aBcYacDzJ6+NIBlNWkYJ86jAdY4b+x5/qSYWr0NIJ +ru+oOaGjswGJU1DhmQjt5D7KvgEnE6e9iYMJJBAG +=r0rf +-----END PGP PUBLIC KEY BLOCK----- \ No newline at end of file diff --git a/scripts/verify-install.sh b/scripts/verify-install.sh index d98264fd5..5133d4b59 100755 --- a/scripts/verify-install.sh +++ b/scripts/verify-install.sh @@ -33,6 +33,7 @@ KEYS+=("3E9BD4436C288039CA827A9200C9E2BC2E45666F suheb") KEYS+=("5F75437E11695F86D50C11BB1AFF9C4DCED6D666 ziggie1984") KEYS+=("C20A78516A0944900EBFCA29961CC8259AE675D4 ViktorT-11") KEYS+=("1583B601BB57CC7CD2DF8A87E08DEA9B12B66AF6 georgetsagk") +KEYS+=("7530B54D5E45A68760E68926019A44857735FD20 gijswijs") TEMP_DIR=$(mktemp -d /tmp/lnd-sig-verification-XXXXXX) From 2ac08a1352781cc20b6c374fe90153a5ffa13541 Mon Sep 17 00:00:00 2001 From: ziggie Date: Tue, 26 May 2026 10:42:52 -0300 Subject: [PATCH 131/134] scripts: add tag-release.sh to safely cut release tags Adds a script that creates a signed annotated release tag only after verifying: 1. The requested tag name matches the version constants committed in HEAD:build/version.go. Catches the failure mode where a release branch is tagged before the version bump has been committed, which would otherwise leave the tagged commit reporting an old version string at runtime. 2. The local HEAD is identical to the upstream lightningnetwork/lnd view of the release branch. A release tag must never point at a commit that has not been merged upstream yet. The upstream remote is discovered by URL rather than by name, since "origin" is conventionally the fork in a "gh repo fork" workflow. The branch defaults to whichever one is currently checked out (typically a release branch such as v0.21.x-branch) and can be overridden with --branch. The script deliberately does not push the tag or auto-bump version.go; both remain explicit human steps. (cherry picked from commit 5320aa0e368c363ec386ec117fda88a03a9a311e) --- scripts/tag-release.sh | 150 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100755 scripts/tag-release.sh diff --git a/scripts/tag-release.sh b/scripts/tag-release.sh new file mode 100755 index 000000000..6ebb52590 --- /dev/null +++ b/scripts/tag-release.sh @@ -0,0 +1,150 @@ +#!/bin/bash +# +# tag-release.sh creates a signed annotated git tag for an lnd release after +# verifying (a) HEAD is in sync with the upstream lightningnetwork/lnd +# branch, and (b) build/version.go at HEAD matches the requested tag. Guards +# against tagging a commit that has not been merged upstream yet, or one +# whose embedded version disagrees with the tag. + +set -euo pipefail + +VERSION_FILE="build/version.go" + +# Match the canonical upstream URL across https / git@ / ssh:// forms, with or +# without a `.git` suffix. We identify the remote by URL because `origin` is +# conventionally the fork in a `gh repo fork` setup. +UPSTREAM_URL_REGEX='[:/]lightningnetwork/lnd(\.git)?$' + +usage() { + cat >&2 < [--branch ] + + Release tag, e.g. v0.20.3-beta.rc1. Must match the + constants defined in ${VERSION_FILE} at HEAD. + --branch Upstream branch to verify HEAD against. Defaults to + the currently checked-out branch (typically a release + branch such as v0.20.x-branch). +EOF + exit 1 +} + +TAG="" +UPSTREAM_BRANCH="" +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) usage ;; + --branch) [[ $# -ge 2 ]] || usage; UPSTREAM_BRANCH="$2"; shift 2 ;; + --branch=*) UPSTREAM_BRANCH="${1#--branch=}"; shift ;; + -*) echo "Unknown flag: $1" >&2; usage ;; + *) [[ -z "${TAG}" ]] || usage; TAG="$1"; shift ;; + esac +done +[[ -n "${TAG}" ]] || usage + +cd "$(git rev-parse --show-toplevel)" + +if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then + echo "Error: tag ${TAG} already exists locally." >&2 + exit 1 +fi + +if [[ -z "${UPSTREAM_BRANCH}" ]]; then + UPSTREAM_BRANCH="$(git symbolic-ref --quiet --short HEAD || true)" + [[ -n "${UPSTREAM_BRANCH}" ]] \ + || { echo "Error: detached HEAD; pass --branch ." >&2; exit 1; } +fi + +# Discover the upstream remote by URL (see UPSTREAM_URL_REGEX). +UPSTREAM_REMOTES=() +while IFS= read -r line; do + UPSTREAM_REMOTES+=("$line") +done < <(git remote -v | awk -v re="${UPSTREAM_URL_REGEX}" \ + '$3 == "(fetch)" && $2 ~ re { print $1 }' | sort -u) + +case "${#UPSTREAM_REMOTES[@]}" in + 0) echo "Error: no git remote points at lightningnetwork/lnd. Add one with" \ + "'git remote add upstream" \ + "https://github.com/lightningnetwork/lnd.git'." >&2 + exit 1 ;; + 1) UPSTREAM_REMOTE="${UPSTREAM_REMOTES[0]}" ;; + *) echo "Error: multiple remotes match lightningnetwork/lnd:" >&2 + printf ' %s\n' "${UPSTREAM_REMOTES[@]}" >&2 + exit 1 ;; +esac + +# Fetch first so every later check runs against confirmed-current upstream +# state. Without this, a stale local HEAD could pass the version-match check +# while still being out of sync with what's on the release branch. +echo "Fetching ${UPSTREAM_REMOTE} ${UPSTREAM_BRANCH}..." +git fetch --quiet "${UPSTREAM_REMOTE}" "${UPSTREAM_BRANCH}" + +# Catch the race where another maintainer has already published this tag. +if git ls-remote --exit-code --tags "${UPSTREAM_REMOTE}" \ + "refs/tags/${TAG}" >/dev/null 2>&1; then + echo "Error: tag ${TAG} already exists on ${UPSTREAM_REMOTE}." >&2 + exit 1 +fi + +# Compare against FETCH_HEAD rather than refs/remotes//: +# FETCH_HEAD is always written by `git fetch `, while the +# remote-tracking ref depends on the user's refspec configuration. +HEAD_SHA="$(git rev-parse HEAD)" +UP_SHA="$(git rev-parse FETCH_HEAD)" +if [[ "${HEAD_SHA}" != "${UP_SHA}" ]]; then + AHEAD="$(git rev-list --count FETCH_HEAD..HEAD)" + BEHIND="$(git rev-list --count HEAD..FETCH_HEAD)" + cat >&2 </dev/null | awk ' + /^[[:space:]]*AppMajor[[:space:]]+uint[[:space:]]*=/ { sub(/.*=[[:space:]]*/,""); sub(/[^0-9].*/,""); print } + /^[[:space:]]*AppMinor[[:space:]]+uint[[:space:]]*=/ { sub(/.*=[[:space:]]*/,""); sub(/[^0-9].*/,""); print } + /^[[:space:]]*AppPatch[[:space:]]+uint[[:space:]]*=/ { sub(/.*=[[:space:]]*/,""); sub(/[^0-9].*/,""); print } + /^[[:space:]]*AppPreRelease[[:space:]]*=/ { match($0,/"[^"]*"/); print substr($0,RSTART+1,RLENGTH-2) } + ' +) + +if [[ -z "${M}" || -z "${m}" || -z "${p}" ]]; then + echo "Error: failed to parse version constants from HEAD:${VERSION_FILE}." \ + >&2 + exit 1 +fi + +# Go treats `01` as an octal literal but %d prints it as decimal; force +# base-10 here so we match build.Version()'s output. +EXPECTED="v$((10#$M)).$((10#$m)).$((10#$p))" +[[ -n "${pre}" ]] && EXPECTED="${EXPECTED}-${pre}" + +echo "Requested: ${TAG}" +echo "Expected: ${EXPECTED} (from HEAD:${VERSION_FILE})" + +if [[ "${TAG}" != "${EXPECTED}" ]]; then + cat >&2 < Date: Tue, 26 May 2026 14:09:25 -0300 Subject: [PATCH 132/134] release: validate version in release CI The release workflow runs from pushed version tags, but the build step was setting SKIP_VERSION_CHECK=1. That made scripts/release.sh exit before running check-tag, so CI did not compare the pushed tag with the version reported from build/version.go. Run the normal release target instead. This keeps release CI from producing artifacts when the tag and embedded lnd version drift apart. (cherry picked from commit e1f03b424fb6f04c32ef0a19038e6259d14eee0e) --- .github/workflows/release.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 012a71628..0ec72187a 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -40,7 +40,7 @@ jobs: run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV - name: build release for all architectures - run: SKIP_VERSION_CHECK=1 make release tag=${{ env.RELEASE_VERSION }} + run: make release tag=${{ env.RELEASE_VERSION }} - name: Create Release uses: lightninglabs/gh-actions/action-gh-release@c7149b6a7818d1c39b36b69e727569897b6f2c5a From 6f7e524d8541a4d58642960d6476824de4cbb639 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Mon, 10 Aug 2026 15:56:03 -0500 Subject: [PATCH 133/134] scripts/keys: add pub key for boris (cherry picked from commit 69176c5d18699f29b7fee78455e8365fcdbfa4af) --- scripts/keys/boris.asc | 56 +++++++++++++++++++++++++++++++++++++++ scripts/verify-install.sh | 1 + 2 files changed, 57 insertions(+) create mode 100644 scripts/keys/boris.asc diff --git a/scripts/keys/boris.asc b/scripts/keys/boris.asc new file mode 100644 index 000000000..2d732d8a2 --- /dev/null +++ b/scripts/keys/boris.asc @@ -0,0 +1,56 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBFkMo/sBEACizMLy5G2eWMKTvpnzbCCgc9vaxVckdDwcfU10YH3JCOjrKyoY +MufKHq862vU+iXURZrkDZI6iK6R/Gbc+yUp3dk/rXgbCubMUi37yCqaEvqM26Eik +D6Hyvfy013GXoAsMSYfPv4c/YDWYRBkNwy2zzH+Ia8nzlfWpaGHUYUUrxHnO4V0W +JBEJYBsGF9R6E/yw1ZZkAZk0UQvrjQI4jAGGzH0r7kWVWPWW0F7x767GvWpyAn9q +Qap0CSUEAKrrpQXwMOopdRYeYWtvE8E82QMap2XJ6zc+n2mmVPlTe/wGKpjCXGIh +TdBHFumHzHUQEUaC/uI4hzMhcEVpTNenLcepWggwWEUqL3l9fvUVJygWjcJjvoi4 +E7fBz7io8me28suA0CMGXhZSA04ZY65EOF6aDhu8ZJOHBi/x8p8EYOWsoEy9wjz9 +r6QqoGs+Vp750GqE7XeXGj8q9ZkMpUaJCANVnZrXw+8Z9bQJv15UgLEGqqgU/7i8 +uLz2IX+Q0d3+Lnnucbsfz/qaNx2/vyNgK95b+YmTpR808Y4ANv18QepW9a8tmamO +3aGW3zDv1U7kZGUYoFllsCzwu4ML7oPfJbk6xOxdgQAToneFCw7PFu32T8Puw9mI +oRbkVAjineLvWeUdNtVbw9lWOXUl+nH8LifC0X5mQrxK2/VmsluXhJUGoQARAQAB +tCBCb3JpcyBOYWdhZXYgPGJuYWdhZXZAZ21haWwuY29tPokCOAQTAQIAIgUCWQyj ++wIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQXqmEcDYay08ZpRAAjCXG +Y9gCkTG0WnIUCRimV72vcFyuRjqA2lEPm5SVvdZFTXf/QW1IOtIXSWvq6WZgSceB +PeHYj5hQiD51138Pxl7uNEw93kqVb4WvYiBGOevdvoKelNY3GX4Hv8lKHEH30WaT +rv35/b5yXtgE48pctrm9AEmy7xxPo42Mlvtp6fhQCWE9aFKPx+NnYGkGOr/MUULh +snvvriTIdY8YxC7yzyKFXoEsfs6WUdWObEg3tbNJ2FmRcyUJQlbzCHBye6Cz1HCX +bnwZh9tp7IZBQCC3xcrhvKiJcmIn/Cvktv34B5P5pko3ashLI/3kuhiLdYdsZR2U +Xt1CVEjb35PdQpGeSq43O/q1l0zGojXkGwNU2DRN3uKgv8e9mb/uYv6PIGPqZsma +KPZZCebSTVa5cDlh0v1E0kval+Tswv7hb6qd49m6SFfM851mS7Z5rOBRPUCNKeQ7 +dNSe9ST+GqCjBz0lYXB1jQL1dgrKceKmiscmVWTaUUl/dUxLJOSciACdpXsByVcp +Z36/wvDHi167NocScwgQRMriKniFvpAhYG4UR861hX6ZZ2ub9qA3aVhp7dx1zXtj +pJYK13FbtNtC01k9q94dAcTC2l4Ef4xGT6GVbKRwm3vC1k58Na5aDtckaNkhpev6 +bI4EfIvw0D7/35okhScLhL6Qf9lnM2jummJtJRCJARwEEAECAAYFAlkMpUMACgkQ +8BzcVabCts4bzwf+PJ4kNjoAyh7F6KgnfnymHcBeZD53sLSRX9k6++YtIdwXlmP+ +7Nud3CskqoYSaaRN4OQLGd83HAy+HHejuep0SJpGGIrUgnO7k+Cj5z8UpPbjjPzn +PDrS4bJ6qsGLv0Fhuu3cNfwFVXMd9u/pY6OsWZd8FVTO8A1sSD0q1lrYplaSK+vl +yae9o7bjRvnSNjQjzFdc9OQWSSefgFr6LSyCFda+7yw5TVtSIC2OXSpgxs/IWxhG +1WyUP0A3yVNFfZJfsSj8+R5SM9GxLMxHbD2Mp7oPTITmR+X7KYEyWVYRSmf4l0Uu +r7gPfCa5RwiA2biky1MbgpRrExsdNn0B8EoBs7kCDQRZDKP7ARAAw+rBcpYy1Bje +uUax39sBn1uZ4tB8Aw+UmebwUq41OktW/RGXdnoBigVPznN36xUZHgXqh2h6mWza +fCaPMx52pNcXwi9m8zxvi1O9BF5OyEWO0vCKTfZgieAUwgjq6EUXBMaINGtYBU/t +A+TAL4MNM+2SHfXUre4MZJfP4EUpZ5ipltgMsmZI7QThn5B1jfh67kVLtfTDJWWR +OflyJ+WHJuL6H1hih589SFVChd3qhGLX/gENtGneXNnq7SyZ/nx3owccI+CYLa8t +DiK3wLaPHfmrXCeBEwmQLi8BHSyhVR9lFuAieYKpFp4GVXdc5+0bzo5VS3FMKvnq +sTdrE003Y+YfcPps+lqr/148DN3DGSspbD9k6Ltcmt3UrtakcAnGQtj0c7pqBI6a +elR3TAW37FaYn69rIpMwTCa9eU3EwgiXOToUMdW20pj7aHDyXB/kF19NLkjUtxwh +6z0b1UFt5r3PWugRxypziLHZ+NYH09Y7vHu+dyxASJi8Zqs71X2ryq7MXJLEtQLq +hfaDYa/m2PN3d0FJYZ0rW3M35H22Xhw9hrgnrAR5TtuOoVcdwXdLbzf4V4LL5TZZ +cGQ1CF7ciDBUxQErPR3bcFVznnd2YPvNegsllWNsS8exTph5qXvkuqIZcdkhAMMK +xCkgpVMY7DyurqAgHm2tx0WaIlm7Ow0AEQEAAYkCHwQYAQIACQUCWQyj+wIbDAAK +CRBeqYRwNhrLT+V0EACVjXWyVFLfe37MGGdopixAu818ZAL1up0hFogVwttyK5lE +e+YqB40Sbr8CxZHLuDLdtr9CRdf/L7L0ycwUGqgsM+JImq1n2hMvxbZwyWrRV1ON +St74XLEs1m5mGNrOrNqbDOZ2fcPkJ3KFGngxN7NXh56gva36mic8ZblEgFmrHgFT +K/tce2YPoOoEPYq94ZLNqGkbpIJZXWRbr+5IQUb/ZD4xTKmg/LviIKltSE8Av0Of +QF9VKJ2rSG3feLVRSiVOSl0Jgm4htUsjh7QZRjwPI6z61UXZRDmdv6LkBa4dP+yT +d8bZd2OVVEmlVaN9tk13oy6wp2nH4LOEdCckq6sF2QPRb6tsE+jSQIHkginOn8FS +hRUsLjVKm0QZ7RZu9BVUJEc9LOMwVf+NIHtQiFFyvKnJuipA/B6PN8dh/T7zmfDX +Jh8I/OlqsO9Bu7TKd/ULgbBsoNQImERLGiEC0vRPCjaxCkv00qvtl869da35Ucv+ +coPgIhWr2erqGVTUw1AySsQwO0svB7IYjfdV+yd+V3IcJbgNfVfDenB/LS8+JHoa +ug4pXpJmyKwi9p51BWXonWap0/4ZmimPgiBBCihuKcVnVXzbXQ9L1pCAifsI3MQS +fYB3DxAdBWRnR0rK1ysuBN/E3tEaJJHnrtWzAW5yZrI60/kvpRpeADMbuXHJQw== +=E/zA +-----END PGP PUBLIC KEY BLOCK----- diff --git a/scripts/verify-install.sh b/scripts/verify-install.sh index 5133d4b59..424f69cf9 100755 --- a/scripts/verify-install.sh +++ b/scripts/verify-install.sh @@ -34,6 +34,7 @@ KEYS+=("5F75437E11695F86D50C11BB1AFF9C4DCED6D666 ziggie1984") KEYS+=("C20A78516A0944900EBFCA29961CC8259AE675D4 ViktorT-11") KEYS+=("1583B601BB57CC7CD2DF8A87E08DEA9B12B66AF6 georgetsagk") KEYS+=("7530B54D5E45A68760E68926019A44857735FD20 gijswijs") +KEYS+=("BCEE34B0F9CD832214CE53005EA98470361ACB4F boris") TEMP_DIR=$(mktemp -d /tmp/lnd-sig-verification-XXXXXX) From 02c6ba46e4b6a78c07cc2c2bdbef2f9bbe0df42e Mon Sep 17 00:00:00 2001 From: ziggie Date: Wed, 12 Aug 2026 12:18:53 -0300 Subject: [PATCH 134/134] build: bump version to v0.20.3 --- build/version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/version.go b/build/version.go index 14d1a16bc..1d581e7e6 100644 --- a/build/version.go +++ b/build/version.go @@ -51,7 +51,7 @@ const ( // AppPreRelease MUST only contain characters from semanticAlphabet per // the semantic versioning spec. - AppPreRelease = "beta.rc1" + AppPreRelease = "beta" ) func init() {