diff --git a/itest/list_on_test.go b/itest/list_on_test.go index 336cec1b0..c33536232 100644 --- a/itest/list_on_test.go +++ b/itest/list_on_test.go @@ -662,4 +662,8 @@ var allTestCases = []*lntest.TestCase{ Name: "payment succeeded htlc remote swept", TestFunc: testPaymentSucceededHTLCRemoteSwept, }, + { + Name: "send to route failed htlc timeout", + TestFunc: testSendToRouteFailHTLCTimeout, + }, } diff --git a/itest/lnd_payment_test.go b/itest/lnd_payment_test.go index 4a93fabfe..317ca02ed 100644 --- a/itest/lnd_payment_test.go +++ b/itest/lnd_payment_test.go @@ -19,6 +19,7 @@ import ( "github.com/lightningnetwork/lnd/lntest/rpc" "github.com/lightningnetwork/lnd/lntest/wait" "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwire" "github.com/stretchr/testify/require" ) @@ -1211,3 +1212,219 @@ func sendPaymentInterceptAndCancel(ht *lntest.HarnessTest, // Cancel the context, which will disconnect the above interceptor. cancelInterceptor() } + +// testSendToRouteFailHTLCTimeout is similar to +// testPaymentFailedHTLCLocalSwept. The only difference is the `SendPayment` is +// replaced with `SendToRouteV2`. It checks that when an outgoing HTLC is timed +// out and claimed onchain via the timeout path, the payment will be marked as +// failed. This test creates a topology from Alice -> Bob, and let Alice send +// payments to Bob. Bob then goes offline, such that Alice's outgoing HTLC will +// time out. Alice will also be restarted to make sure resumed payments are +// also marked as failed. +func testSendToRouteFailHTLCTimeout(ht *lntest.HarnessTest) { + success := ht.Run("fail payment", func(t *testing.T) { + st := ht.Subtest(t) + runSendToRouteFailHTLCTimeout(st, false) + }) + if !success { + return + } + + ht.Run("fail resumed payment", func(t *testing.T) { + st := ht.Subtest(t) + runTestPaymentHTLCTimeout(st, true) + }) +} + +// runSendToRouteFailHTLCTimeout is the helper function that actually runs the +// testSendToRouteFailHTLCTimeout. +func runSendToRouteFailHTLCTimeout(ht *lntest.HarnessTest, restartAlice bool) { + // Set the feerate to be 10 sat/vb. + ht.SetFeeEstimate(2500) + + // Open a channel with 100k satoshis between Alice and Bob with Alice + // being the sole funder of the channel. + chanAmt := btcutil.Amount(100_000) + openChannelParams := lntest.OpenChannelParams{ + Amt: chanAmt, + } + + // Create a two hop network: Alice -> Bob. + chanPoints, nodes := createSimpleNetwork(ht, nil, 2, openChannelParams) + chanPoint := chanPoints[0] + alice, bob := nodes[0], nodes[1] + + // We now create two payments, one above dust and the other below dust, + // and we should see different behavior in terms of when the payment + // will be marked as failed due to the HTLC timeout. + // + // First, create random preimages. + preimage := ht.RandomPreimage() + dustPreimage := ht.RandomPreimage() + + // Get the preimage hashes. + payHash := preimage.Hash() + dustPayHash := dustPreimage.Hash() + + // Create an hold invoice for Bob which expects a payment of 10k + // satoshis from Alice. + const paymentAmt = 20_000 + req := &invoicesrpc.AddHoldInvoiceRequest{ + Value: paymentAmt, + Hash: payHash[:], + // Use a small CLTV value so we can mine fewer blocks. + CltvExpiry: finalCltvDelta, + } + invoice := bob.RPC.AddHoldInvoice(req) + + // Create another hold invoice for Bob which expects a payment of 1k + // satoshis from Alice. + const dustAmt = 1000 + req = &invoicesrpc.AddHoldInvoiceRequest{ + Value: dustAmt, + Hash: dustPayHash[:], + // Use a small CLTV value so we can mine fewer blocks. + CltvExpiry: finalCltvDelta, + } + dustInvoice := bob.RPC.AddHoldInvoice(req) + + // Construct a route to send the non-dust payment. + go func() { + // Query the route to send the payment. + routesReq := &lnrpc.QueryRoutesRequest{ + PubKey: bob.PubKeyStr, + Amt: paymentAmt, + FinalCltvDelta: finalCltvDelta, + } + routes := alice.RPC.QueryRoutes(routesReq) + require.Len(ht, routes.Routes, 1) + + route := routes.Routes[0] + require.Len(ht, route.Hops, 1) + + // Modify the hop to include MPP info. + route.Hops[0].MppRecord = &lnrpc.MPPRecord{ + PaymentAddr: invoice.PaymentAddr, + TotalAmtMsat: int64( + lnwire.NewMSatFromSatoshis(paymentAmt), + ), + } + + // Send the payment with the modified value. + req := &routerrpc.SendToRouteRequest{ + PaymentHash: payHash[:], + Route: route, + } + + // Send the payment and expect no error. + attempt := alice.RPC.SendToRouteV2(req) + require.Equal(ht, lnrpc.HTLCAttempt_FAILED, attempt.Status) + }() + + // Check that the payment is in-flight. + ht.AssertPaymentStatus(alice, preimage, lnrpc.Payment_IN_FLIGHT) + + // Construct a route to send the dust payment. + go func() { + // Query the route to send the payment. + routesReq := &lnrpc.QueryRoutesRequest{ + PubKey: bob.PubKeyStr, + Amt: dustAmt, + FinalCltvDelta: finalCltvDelta, + } + routes := alice.RPC.QueryRoutes(routesReq) + require.Len(ht, routes.Routes, 1) + + route := routes.Routes[0] + require.Len(ht, route.Hops, 1) + + // Modify the hop to include MPP info. + route.Hops[0].MppRecord = &lnrpc.MPPRecord{ + PaymentAddr: dustInvoice.PaymentAddr, + TotalAmtMsat: int64( + lnwire.NewMSatFromSatoshis(dustAmt), + ), + } + + // Send the payment with the modified value. + req := &routerrpc.SendToRouteRequest{ + PaymentHash: dustPayHash[:], + Route: route, + } + + // Send the payment and expect no error. + attempt := alice.RPC.SendToRouteV2(req) + require.Equal(ht, lnrpc.HTLCAttempt_FAILED, attempt.Status) + }() + + // Check that the dust payment is in-flight. + ht.AssertPaymentStatus(alice, dustPreimage, lnrpc.Payment_IN_FLIGHT) + + // Bob should have two incoming HTLC. + ht.AssertIncomingHTLCActive(bob, chanPoint, payHash[:]) + ht.AssertIncomingHTLCActive(bob, chanPoint, dustPayHash[:]) + + // Alice should have two outgoing HTLCs. + ht.AssertOutgoingHTLCActive(alice, chanPoint, payHash[:]) + ht.AssertOutgoingHTLCActive(alice, chanPoint, dustPayHash[:]) + + // Let Bob go offline. + ht.Shutdown(bob) + + // We'll now mine enough blocks to trigger Alice to broadcast her + // commitment transaction due to the fact that the HTLC is about to + // timeout. With the default outgoing broadcast delta of zero, this + // will be the same height as the htlc expiry height. + numBlocks := padCLTV( + uint32(req.CltvExpiry - lncfg.DefaultOutgoingBroadcastDelta), + ) + ht.MineEmptyBlocks(int(numBlocks - 1)) + + // Restart Alice if requested. + if restartAlice { + // Restart Alice to test the resumed payment is canceled. + ht.RestartNode(alice) + } + + // We now subscribe to the payment status. + payStream := alice.RPC.TrackPaymentV2(payHash[:]) + dustPayStream := alice.RPC.TrackPaymentV2(dustPayHash[:]) + + // Mine a block to confirm Alice's closing transaction. + ht.MineBlocksAndAssertNumTxes(1, 1) + + // Now the channel is closed, we expect different behaviors based on + // whether the HTLC is a dust. For dust payment, it should be failed + // now as the HTLC won't go onchain. For non-dust payment, it should + // still be inflight. It won't be marked as failed unless the outgoing + // HTLC is resolved onchain. + // + // Check that the dust payment is failed in both the stream and DB. + ht.AssertPaymentStatus(alice, dustPreimage, lnrpc.Payment_FAILED) + ht.AssertPaymentStatusFromStream(dustPayStream, lnrpc.Payment_FAILED) + + // Check that the non-dust payment is still in-flight. + // + // NOTE: we don't check the payment status from the stream here as + // there's no new status being sent. + ht.AssertPaymentStatus(alice, preimage, lnrpc.Payment_IN_FLIGHT) + + // We now have two possible cases for the non-dust payment: + // - Bob stays offline, and Alice will sweep her outgoing HTLC, which + // makes the payment failed. + // - Bob comes back online, and claims the HTLC on Alice's commitment + // via direct preimage spend, hence racing against Alice onchain. If + // he succeeds, Alice should mark the payment as succeeded. + // + // TODO(yy): test the second case once we have the RPC to clean + // mempool. + + // Since Alice's force close transaction has been confirmed, she should + // sweep her outgoing HTLC in next block. + ht.MineBlocksAndAssertNumTxes(2, 1) + + // We expect the non-dust payment to marked as failed in Alice's + // database and also from her stream. + ht.AssertPaymentStatus(alice, preimage, lnrpc.Payment_FAILED) + ht.AssertPaymentStatusFromStream(payStream, lnrpc.Payment_FAILED) +}