From 944f3f4d63ba4368a8aeef6d37866f69d35be662 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 2 Jun 2026 04:27:43 +0800 Subject: [PATCH 1/6] contractcourt/test: fix success sweeper fixture Make the existing two-stage success resolver test use an output that matches the stored sweep descriptor. This keeps the fixture consistent with the resolver invariant enforced by the following commit. --- contractcourt/htlc_success_resolver_test.go | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/contractcourt/htlc_success_resolver_test.go b/contractcourt/htlc_success_resolver_test.go index f0f5dd8f0ac..0e0391e74da 100644 --- a/contractcourt/htlc_success_resolver_test.go +++ b/contractcourt/htlc_success_resolver_test.go @@ -333,12 +333,10 @@ func TestHtlcSuccessSecondStageResolutionSweeper(t *testing.T) { }, }, TxOut: []*wire.TxOut{ - { - Value: 123, - PkScript: []byte{0xff, 0xff}, - }, + cloneTxOut(testSignDesc.Output), }, } + successHash := successTx.TxHash() reSignedSuccessTx := &wire.MsgTx{ TxIn: []*wire.TxIn{ @@ -365,7 +363,7 @@ func TestHtlcSuccessSecondStageResolutionSweeper(t *testing.T) { successTx.TxOut[0], }, } - reSignedHash := successTx.TxHash() + reSignedHash := reSignedSuccessTx.TxHash() sweepTx := &wire.MsgTx{ TxIn: []*wire.TxIn{ @@ -400,7 +398,7 @@ func TestHtlcSuccessSecondStageResolutionSweeper(t *testing.T) { Amount: testHtlcAmt.ToSatoshis(), ResolverType: channeldb.ResolverTypeIncomingHtlc, ResolverOutcome: channeldb.ResolverOutcomeFirstStage, - SpendTxID: &reSignedHash, + SpendTxID: &successHash, } secondStage := &channeldb.ResolverReport{ @@ -534,6 +532,14 @@ func TestHtlcSuccessSecondStageResolutionSweeper(t *testing.T) { testHtlcSuccess(t, twoStageResolution, checkpoints) } +func cloneTxOut(txOut *wire.TxOut) *wire.TxOut { + pkScript := append([]byte(nil), txOut.PkScript...) + return &wire.TxOut{ + Value: txOut.Value, + PkScript: pkScript, + } +} + // checkpoint holds expected data we expect the resolver to checkpoint itself // to the DB next. type checkpoint struct { From 9512e40d323b1da312f8e62b092a3abc702dcd96 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 2 Jun 2026 04:27:54 +0800 Subject: [PATCH 2/6] contractcourt: reject foreign HTLC success spends The success resolver previously treated any spend of the original HTLC outpoint as confirmation of our own second-level success transaction. That allowed a remote timeout reclaim to be misclassified as a phantom second-level output offered to the sweeper. Validate the spending transaction output against SweepSignDesc.Output before proceeding. If the output does not match, checkpoint the resolver as failed instead of registering the derived outpoint. --- contractcourt/htlc_success_resolver.go | 109 ++++++++++++++++++++----- 1 file changed, 89 insertions(+), 20 deletions(-) diff --git a/contractcourt/htlc_success_resolver.go b/contractcourt/htlc_success_resolver.go index 1770c214a45..1961f0a8564 100644 --- a/contractcourt/htlc_success_resolver.go +++ b/contractcourt/htlc_success_resolver.go @@ -1,6 +1,7 @@ package contractcourt import ( + "bytes" "encoding/binary" "fmt" "io" @@ -10,6 +11,7 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "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" @@ -232,6 +234,55 @@ func (h *htlcSuccessResolver) checkpointClaim(spendTx *chainhash.Hash) error { return h.Checkpoint(h, reports...) } +// checkpointForeignSpend checkpoints the resolver as failed when the original +// HTLC outpoint was spent by a transaction that did not create our expected +// second-level success output. +func (h *htlcSuccessResolver) checkpointForeignSpend( + commitSpend *chainntnfs.SpendDetail) error { + + err := h.ChainArbitratorConfig.PutFinalHtlcOutcome( + h.ChannelArbitratorConfig.ShortChanID, h.htlc.HtlcIndex, false, + ) + if err != nil { + return err + } + + h.ChainArbitratorConfig.HtlcNotifier.NotifyFinalHtlcEvent( + models.CircuitKey{ + ChanID: h.ShortChanID, + HtlcID: h.htlc.HtlcIndex, + }, + channeldb.FinalHtlcInfo{ + Settled: false, + Offchain: false, + }, + ) + + var spendTxID *chainhash.Hash + if commitSpend != nil { + spendTxID = commitSpend.SpenderTxHash + } + h.log.Warnf("HTLC outpoint %v was spent by tx %v, which did not "+ + "create our expected second-level success output", h.outpoint(), + spendTxID) + + // A foreign spend of an incoming HTLC on our commitment means we cannot + // complete the success path. The known production case is the remote + // timeout reclaim. + report := &channeldb.ResolverReport{ + OutPoint: h.outpoint(), + Amount: h.htlc.Amt.ToSatoshis(), + ResolverType: channeldb.ResolverTypeIncomingHtlc, + ResolverOutcome: channeldb.ResolverOutcomeTimeout, + SpendTxID: spendTxID, + } + + h.outputIncubating = false + h.markResolved() + + return h.Checkpoint(h, report) +} + // Stop signals the resolver to cancel any current resolution processes, and // suspend. // @@ -431,6 +482,36 @@ func (h *htlcSuccessResolver) isTaprootFinal() bool { return h.chanType.IsTaprootFinal() } +// successTxOutpoint returns the second-level success output outpoint if the +// spending transaction created the output that this resolver expects to sweep. +func (h *htlcSuccessResolver) successTxOutpoint( + commitSpend *chainntnfs.SpendDetail) (wire.OutPoint, bool) { + + if commitSpend == nil || commitSpend.SpendingTx == nil || + commitSpend.SpenderTxHash == nil { + + return wire.OutPoint{}, false + } + + outputIndex := commitSpend.SpenderInputIndex + if outputIndex >= uint32(len(commitSpend.SpendingTx.TxOut)) { + return wire.OutPoint{}, false + } + + expected := h.htlcResolution.SweepSignDesc.Output + actual := commitSpend.SpendingTx.TxOut[outputIndex] + if expected == nil || actual.Value != expected.Value || + !bytes.Equal(actual.PkScript, expected.PkScript) { + + return wire.OutPoint{}, false + } + + return wire.OutPoint{ + Hash: *commitSpend.SpenderTxHash, + Index: outputIndex, + }, true +} + // sweepRemoteCommitOutput creates a sweep request to sweep the HTLC output on // the remote commitment via the direct preimage-spend. func (h *htlcSuccessResolver) sweepRemoteCommitOutput() error { @@ -560,6 +641,10 @@ func (h *htlcSuccessResolver) sweepSuccessTxOutput() error { if err != nil { return err } + op, ok := h.successTxOutpoint(commitSpend) + if !ok { + return h.checkpointForeignSpend(commitSpend) + } // The HTLC success tx has a CSV lock that we must wait for, and if // this is a lease enforced channel and we're the imitator, we may need @@ -583,16 +668,6 @@ func (h *htlcSuccessResolver) sweepSuccessTxOutput() error { h, h.htlc.RHash[:], waitHeight) } - // We'll use this input index to determine the second-level output - // index on the transaction, as the signatures requires the indexes to - // be the same. We don't look for the second-level output script - // directly, as there might be more than one HTLC output to the same - // pkScript. - op := &wire.OutPoint{ - Hash: *commitSpend.SpenderTxHash, - Index: commitSpend.SpenderInputIndex, - } - // Let the sweeper sweep the second-level output now that the // CSV/CLTV locks have expired. var witType input.StandardWitnessType @@ -605,7 +680,7 @@ func (h *htlcSuccessResolver) sweepSuccessTxOutput() error { witType = input.HtlcAcceptedSuccessSecondLevel } inp := h.makeSweepInput( - op, witType, + &op, witType, input.LeaseHtlcAcceptedSuccessSecondLevel, &h.htlcResolution.SweepSignDesc, h.htlcResolution.CsvDelay, uint32(commitSpend.SpendingHeight), @@ -703,15 +778,9 @@ func (h *htlcSuccessResolver) resolveSuccessTx() error { if err != nil { return err } - - // We'll use this input index to determine the second-level output - // index on the transaction, as the signatures requires the indexes to - // be the same. We don't look for the second-level output script - // directly, as there might be more than one HTLC output to the same - // pkScript. - op := wire.OutPoint{ - Hash: *commitSpend.SpenderTxHash, - Index: commitSpend.SpenderInputIndex, + op, ok := h.successTxOutpoint(commitSpend) + if !ok { + return h.checkpointForeignSpend(commitSpend) } // If the 2nd-stage sweeping has already been started, we can From 5a6f78944f57bf411cbb2d660289442b5c8867a7 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 2 Jun 2026 04:28:09 +0800 Subject: [PATCH 3/6] contractcourt/test: cover foreign HTLC spends Add regression coverage for both success-resolver paths that can observe a foreign spend: the initial resolve path and the restart path after output incubation. The tests assert that no phantom second-level output is handed to the sweeper and that the final HTLC outcome is failed. --- contractcourt/htlc_success_resolver_test.go | 271 ++++++++++++++++++++ 1 file changed, 271 insertions(+) diff --git a/contractcourt/htlc_success_resolver_test.go b/contractcourt/htlc_success_resolver_test.go index 0e0391e74da..e069765a259 100644 --- a/contractcourt/htlc_success_resolver_test.go +++ b/contractcourt/htlc_success_resolver_test.go @@ -37,6 +37,7 @@ type htlcResolverTestContext struct { resolutionChan chan ResolutionMsg finalHtlcOutcomeStored bool + finalHtlcSettled bool t *testing.T } @@ -100,6 +101,7 @@ func newHtlcResolverTestContext(t *testing.T, htlcId uint64, settled bool) error { testCtx.finalHtlcOutcomeStored = true + testCtx.finalHtlcSettled = settled return nil }, @@ -532,6 +534,255 @@ func TestHtlcSuccessSecondStageResolutionSweeper(t *testing.T) { testHtlcSuccess(t, twoStageResolution, checkpoints) } +// TestHtlcSuccessResolverRejectsForeignSpend asserts that a spender which +// does not create our expected second-level success output does not get handed +// to the sweeper as a phantom input. +func TestHtlcSuccessResolverRejectsForeignSpend(t *testing.T) { + defer timeout()() + + // Arrange: set up a resolver that must first publish its HTLC success + // tx, then observe the original HTLC output being spent by a foreign + // transaction. + commitOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{0x01}, + Index: 2, + } + resolution := newTestAnchorSuccessResolution(commitOutpoint) + foreignTx, foreignHash := newForeignSuccessSpend(commitOutpoint) + expectedReport := &channeldb.ResolverReport{ + OutPoint: commitOutpoint, + Amount: testHtlcAmt.ToSatoshis(), + ResolverType: channeldb.ResolverTypeIncomingHtlc, + ResolverOutcome: channeldb.ResolverOutcomeTimeout, + SpendTxID: &foreignHash, + } + ctx := newTestHtlcSuccessContext(t, resolution, false) + + checkpointChan := make(chan struct{}, 1) + ctx.checkpoint = func(resolver ContractResolver, + reports ...*channeldb.ResolverReport) error { + + successResolver, ok := resolver.(*htlcSuccessResolver) + require.True(t, ok) + + require.True(t, successResolver.IsResolved()) + require.False(t, successResolver.outputIncubating) + require.True(t, ctx.finalHtlcOutcomeStored) + require.False(t, ctx.finalHtlcSettled) + require.Equal(t, []*channeldb.ResolverReport{ + expectedReport, + }, reports) + + checkpointChan <- struct{}{} + + return nil + } + + // Act: start resolution and wait for the resolver to offer the + // first-stage HTLC output to the sweeper. + ctx.resolve() + + resolver := successResolverFromContext(t, ctx) + sweeper := mockSweeperFromResolver(t, resolver) + select { + case inp := <-sweeper.sweptInputs: + require.Equal(t, commitOutpoint, inp.OutPoint()) + case <-time.After(time.Second): + t.Fatal("expected first-stage input to be swept") + } + + // Act: deliver a foreign spend of the commitment HTLC output. + ctx.notifier.SpendChan <- &chainntnfs.SpendDetail{ + SpendingTx: foreignTx, + SpenderTxHash: &foreignHash, + SpenderInputIndex: 0, + SpendingHeight: 10, + SpentOutPoint: &commitOutpoint, + } + + select { + case <-checkpointChan: + case <-time.After(time.Second): + t.Fatal("expected foreign spend checkpoint") + } + ctx.waitForResult() + + // Assert: the resolver failed the HTLC and did not register the foreign + // transaction output as a second-stage sweep. + assertNoSweptInput(t, sweeper) + assertFinalHtlcFailed(t, ctx) +} + +// TestHtlcSuccessResolverRejectsForeignSpendOnRestart asserts that the same +// foreign-spend validation is applied after restart, when the resolver is +// already waiting for the second-level output to mature. +func TestHtlcSuccessResolverRejectsForeignSpendOnRestart(t *testing.T) { + defer timeout()() + + // Arrange: set up a restarted resolver that believes the success + // transaction is already incubating. + commitOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{0x02}, + Index: 3, + } + resolution := newTestAnchorSuccessResolution(commitOutpoint) + foreignTx, foreignHash := newForeignSuccessSpend(commitOutpoint) + expectedReport := &channeldb.ResolverReport{ + OutPoint: commitOutpoint, + Amount: testHtlcAmt.ToSatoshis(), + ResolverType: channeldb.ResolverTypeIncomingHtlc, + ResolverOutcome: channeldb.ResolverOutcomeTimeout, + SpendTxID: &foreignHash, + } + ctx := newTestHtlcSuccessContext(t, resolution, true) + + checkpointChan := make(chan struct{}, 1) + ctx.checkpoint = func(resolver ContractResolver, + reports ...*channeldb.ResolverReport) error { + + successResolver, ok := resolver.(*htlcSuccessResolver) + require.True(t, ok) + + require.True(t, successResolver.IsResolved()) + require.False(t, successResolver.outputIncubating) + require.True(t, ctx.finalHtlcOutcomeStored) + require.False(t, ctx.finalHtlcSettled) + require.Equal(t, []*channeldb.ResolverReport{ + expectedReport, + }, reports) + + checkpointChan <- struct{}{} + + return nil + } + + // Act: start resolution from the incubating state and deliver a foreign + // spend of the original commitment HTLC output. + ctx.resolve() + ctx.notifier.SpendChan <- &chainntnfs.SpendDetail{ + SpendingTx: foreignTx, + SpenderTxHash: &foreignHash, + SpenderInputIndex: 0, + SpendingHeight: 10, + SpentOutPoint: &commitOutpoint, + } + + select { + case <-checkpointChan: + case <-time.After(time.Second): + t.Fatal("expected foreign spend checkpoint") + } + ctx.waitForResult() + + // Assert: the restarted resolver fails without handing the foreign + // output to the sweeper. + resolver := successResolverFromContext(t, ctx) + assertNoSweptInput(t, mockSweeperFromResolver(t, resolver)) + assertFinalHtlcFailed(t, ctx) +} + +// newTestHtlcSuccessContext creates a success resolver test context with the +// supplied incoming HTLC resolution and incubation state. +func newTestHtlcSuccessContext(t *testing.T, + resolution lnwallet.IncomingHtlcResolution, + outputIncubating bool) *htlcResolverTestContext { + + return newHtlcResolverTestContext( + t, func(htlc channeldb.HTLC, + cfg ResolverConfig) ContractResolver { + + r := newSuccessResolver( + resolution, 0, htlc, 0, cfg, + ) + r.outputIncubating = outputIncubating + + return r + }, + ) +} + +// successResolverFromContext returns the success resolver installed in a test +// context. +func successResolverFromContext(t *testing.T, + ctx *htlcResolverTestContext) *htlcSuccessResolver { + + t.Helper() + + resolver, ok := ctx.resolver.(*htlcSuccessResolver) + require.True(t, ok) + + return resolver +} + +// mockSweeperFromResolver returns the mock sweeper installed on a success +// resolver. +func mockSweeperFromResolver(t *testing.T, + resolver *htlcSuccessResolver) *mockSweeper { + + t.Helper() + + sweeper, ok := resolver.Sweeper.(*mockSweeper) + require.True(t, ok) + + return sweeper +} + +// newTestAnchorSuccessResolution returns an anchor-channel success resolution +// whose second-level output matches the shared test sign descriptor. +func newTestAnchorSuccessResolution( + commitOutpoint wire.OutPoint) lnwallet.IncomingHtlcResolution { + + successTx := &wire.MsgTx{ + TxIn: []*wire.TxIn{ + { + PreviousOutPoint: commitOutpoint, + }, + }, + TxOut: []*wire.TxOut{ + cloneTxOut(testSignDesc.Output), + }, + } + successHash := successTx.TxHash() + + return lnwallet.IncomingHtlcResolution{ + Preimage: [32]byte{}, + CsvDelay: 4, + SignedSuccessTx: successTx, + SignDetails: &input.SignDetails{ + SignDesc: testSignDesc, + PeerSig: testSig, + }, + ClaimOutpoint: wire.OutPoint{ + Hash: successHash, + Index: 0, + }, + SweepSignDesc: testSignDesc, + } +} + +// newForeignSuccessSpend returns a transaction that spends the commitment HTLC +// outpoint without creating the expected second-level success output. +func newForeignSuccessSpend( + commitOutpoint wire.OutPoint) (*wire.MsgTx, chainhash.Hash) { + + foreignTx := &wire.MsgTx{ + TxIn: []*wire.TxIn{ + { + PreviousOutPoint: commitOutpoint, + }, + }, + TxOut: []*wire.TxOut{ + { + Value: testSignDesc.Output.Value, + PkScript: []byte{0x51}, + }, + }, + } + + return foreignTx, foreignTx.TxHash() +} + +// cloneTxOut returns a copy of a transaction output. func cloneTxOut(txOut *wire.TxOut) *wire.TxOut { pkScript := append([]byte(nil), txOut.PkScript...) return &wire.TxOut{ @@ -540,6 +791,26 @@ func cloneTxOut(txOut *wire.TxOut) *wire.TxOut { } } +// assertNoSweptInput asserts that the sweeper has not received another input. +func assertNoSweptInput(t *testing.T, sweeper *mockSweeper) { + t.Helper() + + select { + case inp := <-sweeper.sweptInputs: + t.Fatalf("unexpected swept input: %v", inp.OutPoint()) + default: + } +} + +// assertFinalHtlcFailed asserts that the final HTLC outcome was persisted as a +// failure. +func assertFinalHtlcFailed(t *testing.T, ctx *htlcResolverTestContext) { + t.Helper() + + require.True(t, ctx.finalHtlcOutcomeStored) + require.False(t, ctx.finalHtlcSettled) +} + // checkpoint holds expected data we expect the resolver to checkpoint itself // to the DB next. type checkpoint struct { From 098cb36fc54d944420eb9353117a26360c79b5b5 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 2 Jun 2026 04:28:21 +0800 Subject: [PATCH 4/6] contractcourt: pass launch height to HTLC registry The incoming contest resolver launch-time invoice-registry lookup passed currentHeight=0, bypassing registry-side expiry checks for immediate resolutions. Fetch the current best height in Launch and pass it into the immediate registry lookup. Known preimages can still launch the success resolver after expiry so direct preimage sweeps are not suppressed on restart. --- contractcourt/htlc_incoming_contest_resolver.go | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/contractcourt/htlc_incoming_contest_resolver.go b/contractcourt/htlc_incoming_contest_resolver.go index 70349808c62..262ebd01e81 100644 --- a/contractcourt/htlc_incoming_contest_resolver.go +++ b/contractcourt/htlc_incoming_contest_resolver.go @@ -90,9 +90,13 @@ func (h *htlcIncomingContestResolver) Launch() error { } h.log.Debugf("launching contest resolver...") + _, bestHeight, err := h.ChainIO.GetBestBlock() + if err != nil { + return err + } // Query the preimage and apply it if we already know it. - applied, err := h.findAndapplyPreimage() + applied, err := h.findAndapplyPreimage(bestHeight) if err != nil { return err } @@ -615,7 +619,8 @@ 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) { +func (h *htlcIncomingContestResolver) findAndapplyPreimage( + currentHeight int32) (bool, error) { // Query to see if we already know the preimage. preimage, ok := h.PreimageDB.LookupPreimage(h.htlc.RHash) @@ -658,13 +663,13 @@ func (h *htlcIncomingContestResolver) findAndapplyPreimage() (bool, error) { // immediately, we'll assume we don't know it yet and let the `Resolve` // handle the waiting. // - // NOTE: we use a nil subscriber here and a zero current height as we - // are only interested in the settle resolution. + // NOTE: we use a nil subscriber here as we are only interested in the + // settle resolution. // // TODO(yy): move this logic to link and let the preimage be accessed // via the preimage beacon. resolution, err := h.Registry.NotifyExitHopHtlc( - h.htlc.RHash, h.htlc.Amt, h.htlcExpiry, 0, + h.htlc.RHash, h.htlc.Amt, h.htlcExpiry, currentHeight, circuitKey, nil, h.htlc.CustomRecords, payload, ) if err != nil { From 5c40cf12379c0a327764e0b6a34c5e4e698a047b Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 2 Jun 2026 04:28:33 +0800 Subject: [PATCH 5/6] contractcourt/test: cover HTLC launch height Add coverage for Launch still using an already-known preimage after HTLC expiry, matching restart behavior where the preimage was learned before shutdown. Also assert launch-time registry lookups use the current chain height instead of zero. --- .../htlc_incoming_contest_resolver_test.go | 120 ++++++++++++++++++ contractcourt/mock_registry_test.go | 8 ++ 2 files changed, 128 insertions(+) diff --git a/contractcourt/htlc_incoming_contest_resolver_test.go b/contractcourt/htlc_incoming_contest_resolver_test.go index f17190e96e8..3ca9274bd82 100644 --- a/contractcourt/htlc_incoming_contest_resolver_test.go +++ b/contractcourt/htlc_incoming_contest_resolver_test.go @@ -4,6 +4,7 @@ import ( "bytes" "io" "testing" + "time" "github.com/btcsuite/btcd/wire" sphinx "github.com/lightningnetwork/lightning-onion" @@ -116,6 +117,119 @@ func TestHtlcIncomingResolverFwdTimeout(t *testing.T) { ctx.waitForResult(false) } +// TestHtlcIncomingResolverLaunchUsesKnownPreimageAfterExpiry asserts that +// Launch still offers the HTLC to the sweeper when the preimage was already +// learned before restart, even if the HTLC is now expired. +func TestHtlcIncomingResolverLaunchUsesKnownPreimageAfterExpiry(t *testing.T) { + t.Parallel() + defer timeout()() + + tests := []struct { + name string + isExit bool + setup func(*incomingResolverTestContext) + assert func(*testing.T, *incomingResolverTestContext) + }{ + { + name: "preimage db", + isExit: false, + setup: func(ctx *incomingResolverTestContext) { + ctx.witnessBeacon.lookupPreimage[testResHash] = + testResPreimage + }, + assert: func(t *testing.T, + ctx *incomingResolverTestContext) { + + require.Empty(t, ctx.registry.immediateNotify) + }, + }, + { + name: "invoice registry replay", + isExit: true, + setup: func(ctx *incomingResolverTestContext) { + ctx.registry.notifyResolution = + invoices.NewSettleResolution( + testResPreimage, + testResCircuitKey, + testAcceptHeight, + invoices.ResultReplayToSettled, + ) + }, + assert: func(t *testing.T, + ctx *incomingResolverTestContext) { + + require.Len(t, ctx.registry.immediateNotify, 1) + notify := ctx.registry.immediateNotify[0] + require.Equal( + t, ctx.chainIO.BestHeight, + notify.currentHeight, + ) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // Arrange: restart at the HTLC expiry with the preimage + // already available from this test's source. + ctx := newIncomingResolverTestContext(t, test.isExit) + ctx.resolver.htlcExpiry = testInitialBlockHeight + test.setup(ctx) + + // Act: launch without entering Resolve's block epoch + // loop. + require.NoError(t, ctx.resolver.Launch()) + + // Assert: the known preimage still starts the direct + // sweep path after expiry. + require.True(t, ctx.resolver.isLaunched()) + preimage := lntypes.Preimage( + ctx.resolver.htlcResolution.Preimage, + ) + require.Equal( + t, testResPreimage, preimage, + ) + + sweeper, ok := ctx.resolver.Sweeper.(*mockSweeper) + require.True(t, ok) + select { + case <-sweeper.sweptInputs: + case <-time.After(time.Second): + t.Fatal("expected known preimage sweep") + } + + test.assert(t, ctx) + }) + } +} + +// TestHtlcIncomingResolverLaunchUsesCurrentHeight asserts that Launch uses the +// current chain height for its immediate invoice-registry lookup instead of the +// historical zero height placeholder. +func TestHtlcIncomingResolverLaunchUsesCurrentHeight(t *testing.T) { + t.Parallel() + defer timeout()() + + // Arrange: set up an exit-hop resolver with a registry resolution that + // Launch can consume immediately. + ctx := newIncomingResolverTestContext(t, true) + ctx.chainIO.BestHeight = testInitialBlockHeight + 1 + ctx.registry.notifyResolution = invoices.NewSettleResolution( + testResPreimage, testResCircuitKey, testAcceptHeight, + invoices.ResultReplayToSettled, + ) + + // Act: launch performs the immediate invoice-registry lookup. + require.NoError(t, ctx.resolver.Launch()) + + // Assert: the registry saw the chain height returned by ChainIO. + require.Len(t, ctx.registry.immediateNotify, 1) + require.Equal( + t, ctx.chainIO.BestHeight, + ctx.registry.immediateNotify[0].currentHeight, + ) +} + // TestHtlcIncomingResolverExitSettle tests resolution of an exit hop htlc for // which the invoice has already been settled when the resolver starts. func TestHtlcIncomingResolverExitSettle(t *testing.T) { @@ -306,6 +420,7 @@ type incomingResolverTestContext struct { witnessBeacon *mockWitnessBeacon resolver *htlcIncomingContestResolver notifier *mock.ChainNotifier + chainIO *mock.ChainIO onionProcessor *mockOnionProcessor resolveErr chan error nextResolver ContractResolver @@ -320,6 +435,9 @@ func newIncomingResolverTestContext(t *testing.T, isExit bool) *incomingResolver ConfChan: make(chan *chainntnfs.TxConfirmation), } witnessBeacon := newMockWitnessBeacon() + chainIO := &mock.ChainIO{ + BestHeight: testInitialBlockHeight, + } registry := &mockRegistry{ notifyChan: make(chan notifyExitHopData, 1), } @@ -332,6 +450,7 @@ func newIncomingResolverTestContext(t *testing.T, isExit bool) *incomingResolver registry: registry, witnessBeacon: witnessBeacon, notifier: notifier, + chainIO: chainIO, onionProcessor: onionProcessor, t: t, } @@ -359,6 +478,7 @@ func newIncomingResolverTestContext(t *testing.T, isExit bool) *incomingResolver return nil }, Sweeper: newMockSweeper(), + ChainIO: chainIO, }, PutResolverReport: func(_ kvdb.RwTx, _ *channeldb.ResolverReport) error { diff --git a/contractcourt/mock_registry_test.go b/contractcourt/mock_registry_test.go index 0530ab51dd4..68317a66353 100644 --- a/contractcourt/mock_registry_test.go +++ b/contractcourt/mock_registry_test.go @@ -21,6 +21,7 @@ type mockRegistry struct { notifyChan chan notifyExitHopData notifyErr error notifyResolution invoices.HtlcResolution + immediateNotify []notifyExitHopData } func (r *mockRegistry) NotifyExitHopHtlc(payHash lntypes.Hash, @@ -31,6 +32,13 @@ func (r *mockRegistry) NotifyExitHopHtlc(payHash lntypes.Hash, // Exit early if the notification channel is nil. if hodlChan == nil { + r.immediateNotify = append(r.immediateNotify, notifyExitHopData{ + payHash: payHash, + paidAmount: paidAmount, + expiry: expiry, + currentHeight: currentHeight, + }) + return r.notifyResolution, r.notifyErr } From 0a2f9d378e0191294c330a90a28e0b0bb0b7891e Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 2 Jun 2026 04:28:45 +0800 Subject: [PATCH 6/6] docs: release-note HTLC resolver fixes Document the user-visible fix for issue #10840 in the v0.21.1 release notes. The note covers both the foreign-spend validation and the launch-time registry height fix. --- docs/release-notes/release-notes-0.21.1.md | 69 ++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 docs/release-notes/release-notes-0.21.1.md diff --git a/docs/release-notes/release-notes-0.21.1.md b/docs/release-notes/release-notes-0.21.1.md new file mode 100644 index 00000000000..5bc28493826 --- /dev/null +++ b/docs/release-notes/release-notes-0.21.1.md @@ -0,0 +1,69 @@ +# 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) + - [Robustness](#robustness) + - [Tooling and Documentation](#tooling-and-documentation) +- [Contributors (Alphabetical Order)](#contributors-alphabetical-order) + +# Bug Fixes + +* [Fixed an issue](https://github.com/lightningnetwork/lnd/issues/10840) + where an incoming HTLC resolver could treat a foreign spend of the + commitment HTLC output as its own success transaction, causing a + phantom second-level input to be offered to the sweeper. The resolver + now validates the spending transaction's expected output before + sweeping it, and passes the current chain height into launch-time + invoice-registry lookups. + +# 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 + +## Robustness + +## Tooling and Documentation + +# Contributors (Alphabetical Order)