itest: cover on-chain interceptor settlement
What changed, and why it matters
This commit adds new integration tests for the Lightning Network Daemon (LND). The tests check what happens when a payment is deliberately held by an external interceptor and the underlying channel is force-closed on-chain. The commit message and comments describe two related bug paths: after a restart, an on-chain held HTLC could be accidentally dropped when a new block is mined because it lacked an auto-fail height; and without a restart, an old off-chain held entry could block settlement from reaching the on-chain resolver. The commit itself only adds tests, not fixes, so it documents and reproduces the issue rather than changing production code.
Treat this commit as a regression-test addition for a bug that should be verified as already fixed in prior or accompanying production commits. Review the actual fix in the interceptor/registry and contractcourt resolver code to ensure (1) on-chain intercepted HTLCs carry a correct auto-fail height or are not evicted by block height checks, and (2) duplicate off-chain/on-chain held entries are de-duplicated so settlement reaches the on-chain resolver. Run the new integration tests against the current production code to confirm they pass.
Security signals we found
On-chain HTLC settlement failure after force-close and restart
Held HTLC entry eviction due to missing auto-fail height for on-chain packets
Duplicate held-entry path may block on-chain contest resolver settlement
Payment could remain unresolved or funds could be locked if sweep is not registered
Test-only commit; production fix not visible in this patch
Evidence from the diff
The diff adds two integration tests in itest/lnd_forward_interceptor_test.go and registers them in itest/list_on_test.go. testForwardInterceptorOnChainSettleAfterRestart reproduces a scenario where Bob restarts after an incoming channel force-close, the contractcourt re-offers the HTLC through the witness beacon/on-chain interceptor path, and a subsequent mined block previously caused the held entry to be evicted because the on-chain packet had no auto-fail height. testForwardInterceptorOnChainSettleNoRestart covers the duplicate-entry path where an existing off-chain held entry must not prevent the on-chain contest resolver from receiving the settlement. The commit is test-only; any production fix is not present in this diff.
Changed components
itest/lnd_forward_interceptor_test.goitest/list_on_test.goHtlcInterceptor RPC flowcontractcourt on-chain contest resolverwitness beacon re-offer pathsweeper registration for HTLC success inputsInspect captured patch +174 / −0
diff --git a/itest/list_on_test.go b/itest/list_on_test.go
index d5b3d50..54b203f 100644
--- a/itest/list_on_test.go
+++ b/itest/list_on_test.go
@@ -455,6 +455,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: "delete forwarding history",
TestFunc: testDeleteForwardingHistory,
diff --git a/itest/lnd_forward_interceptor_test.go b/itest/lnd_forward_interceptor_test.go
index 0724429..7391d41 100644
--- a/itest/lnd_forward_interceptor_test.go
+++ b/itest/lnd_forward_interceptor_test.go
@@ -484,6 +484,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 {
Why this scored 57/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.