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 9b31ba83ef)
This commit is contained in:
ziggie 2026-06-22 08:17:05 -03:00
parent 77989b955d
commit de8ca73610
No known key found for this signature in database
GPG key ID: 1AFF9C4DCED6D666
2 changed files with 174 additions and 0 deletions

View file

@ -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,

View file

@ -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 {