From 9a972e1b0cfdfa1666a5740694c6e5953f2f5e1d Mon Sep 17 00:00:00 2001 From: ffranr Date: Tue, 30 Apr 2024 17:51:50 +0100 Subject: [PATCH] itest: add basic invoice HTLC modifier integration test This commit introduces a basic integration test for the invoice HTLC modifier. The test covers scenarios where an invoice is settled with a payment that is less than the invoice amount, facilitated by the invoice HTLC modifier. --- itest/list_on_test.go | 4 + itest/lnd_forward_interceptor_test.go | 80 ++++-- itest/lnd_invoice_acceptor_test.go | 358 ++++++++++++++++++++++++++ 3 files changed, 421 insertions(+), 21 deletions(-) create mode 100644 itest/lnd_invoice_acceptor_test.go diff --git a/itest/list_on_test.go b/itest/list_on_test.go index 5a12cee4b..c5fa7cebf 100644 --- a/itest/list_on_test.go +++ b/itest/list_on_test.go @@ -466,6 +466,10 @@ var allTestCases = []*lntest.TestCase{ Name: "forward interceptor restart", TestFunc: testForwardInterceptorRestart, }, + { + Name: "invoice HTLC modifier basic", + TestFunc: testInvoiceHtlcModifierBasic, + }, { Name: "zero conf channel open", TestFunc: testZeroConfChannelOpen, diff --git a/itest/lnd_forward_interceptor_test.go b/itest/lnd_forward_interceptor_test.go index 78cd4ff65..6148ba7f1 100644 --- a/itest/lnd_forward_interceptor_test.go +++ b/itest/lnd_forward_interceptor_test.go @@ -10,6 +10,7 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/lightningnetwork/lnd/chainreg" "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnrpc/invoicesrpc" "github.com/lightningnetwork/lnd/lnrpc/routerrpc" "github.com/lightningnetwork/lnd/lntest" "github.com/lightningnetwork/lnd/lntest/node" @@ -374,8 +375,13 @@ func testForwardInterceptorModifiedHtlc(ht *lntest.HarnessTest) { // Connect an interceptor to Bob's node. bobInterceptor, cancelBobInterceptor := bob.RPC.HtlcInterceptor() + // We're going to modify the payment amount and want Carol to accept the + // payment, so we set up an invoice acceptor on Dave. + carolAcceptor, carolCancel := carol.RPC.InvoiceHtlcModifier() + defer carolCancel() + // Prepare the test cases. - invoiceValueAmtMsat := int64(1000) + invoiceValueAmtMsat := int64(20_000_000) req := &lnrpc.Invoice{ValueMsat: invoiceValueAmtMsat} addResponse := carol.RPC.AddInvoice(req) invoice := carol.RPC.LookupInvoice(addResponse.RHash) @@ -408,10 +414,10 @@ func testForwardInterceptorModifiedHtlc(ht *lntest.HarnessTest) { crValue := []byte("custom-records-test-value") customRecords[crKey] = crValue - // TODO(guggero): Actually modify the amount once we have the invoice - // interceptor and can accept a lower amount. - newOutAmountMsat := packet.OutgoingAmountMsat - + // Modify the amount of the HTLC, so we send out less than the original + // amount. + const modifyAmount = 5_000_000 + newOutAmountMsat := packet.OutgoingAmountMsat - modifyAmount err := bobInterceptor.Send(&routerrpc.ForwardHtlcInterceptResponse{ IncomingCircuitKey: packet.IncomingCircuitKey, OutAmountMsat: newOutAmountMsat, @@ -420,6 +426,17 @@ func testForwardInterceptorModifiedHtlc(ht *lntest.HarnessTest) { }) require.NoError(ht, err, "failed to send request") + invoicePacket := ht.ReceiveInvoiceHtlcModification(carolAcceptor) + require.EqualValues( + ht, newOutAmountMsat, invoicePacket.ExitHtlcAmt, + ) + amtPaid := newOutAmountMsat + modifyAmount + err = carolAcceptor.Send(&invoicesrpc.HtlcModifyResponse{ + CircuitKey: invoicePacket.ExitHtlcCircuitKey, + AmtPaid: &amtPaid, + }) + require.NoError(ht, err, "carol acceptor response") + // Cancel the context, which will disconnect Bob's interceptor. cancelBobInterceptor() @@ -473,17 +490,23 @@ func testForwardInterceptorWireRecords(ht *lntest.HarnessTest) { carolInterceptor, cancelCarolInterceptor := carol.RPC.HtlcInterceptor() defer cancelCarolInterceptor() - req := &lnrpc.Invoice{ValueMsat: 1000} + // We're going to modify the payment amount and want Dave to accept the + // payment, so we set up an invoice acceptor on Dave. + daveAcceptor, daveCancel := dave.RPC.InvoiceHtlcModifier() + defer daveCancel() + + req := &lnrpc.Invoice{ValueMsat: 20_000_000} addResponse := dave.RPC.AddInvoice(req) invoice := dave.RPC.LookupInvoice(addResponse.RHash) + customRecords := map[uint64][]byte{ + 65537: []byte("test"), + } sendReq := &routerrpc.SendPaymentRequest{ - PaymentRequest: invoice.PaymentRequest, - TimeoutSeconds: int32(wait.PaymentTimeout.Seconds()), - FeeLimitMsat: noFeeLimitMsat, - FirstHopCustomRecords: map[uint64][]byte{ - 65537: []byte("test"), - }, + PaymentRequest: invoice.PaymentRequest, + TimeoutSeconds: int32(wait.PaymentTimeout.Seconds()), + FeeLimitMsat: noFeeLimitMsat, + FirstHopCustomRecords: customRecords, } _ = alice.RPC.SendPayment(sendReq) @@ -499,14 +522,10 @@ func testForwardInterceptorWireRecords(ht *lntest.HarnessTest) { require.True(ht, ok, "expected custom record") require.Equal(ht, []byte("test"), val) - // TODO(guggero): Actually modify the amount once we have the invoice - // interceptor and can accept a lower amount. - newOutAmountMsat := packet.OutgoingAmountMsat - + // Just resume the payment on Bob. err := bobInterceptor.Send(&routerrpc.ForwardHtlcInterceptResponse{ IncomingCircuitKey: packet.IncomingCircuitKey, - OutAmountMsat: newOutAmountMsat, - Action: actionResumeModify, + Action: actionResume, }) require.NoError(ht, err, "failed to send request") @@ -515,13 +534,32 @@ func testForwardInterceptorWireRecords(ht *lntest.HarnessTest) { packet = ht.ReceiveHtlcInterceptor(carolInterceptor) require.Len(ht, packet.InWireCustomRecords, 0) - // Just resume the payment on Carol. + // We're going to tell Carol to forward 5k sats less to Dave. We need to + // set custom records on the HTLC as well, to make sure the HTLC isn't + // rejected outright and actually gets to the invoice acceptor. + const modifyAmount = 5_000_000 + newOutAmountMsat := packet.OutgoingAmountMsat - modifyAmount err = carolInterceptor.Send(&routerrpc.ForwardHtlcInterceptResponse{ - IncomingCircuitKey: packet.IncomingCircuitKey, - Action: actionResume, + IncomingCircuitKey: packet.IncomingCircuitKey, + OutAmountMsat: newOutAmountMsat, + OutWireCustomRecords: customRecords, + Action: actionResumeModify, }) require.NoError(ht, err, "carol interceptor response") + // The payment should get to Dave, and we should be able to intercept + // and modify it, telling Dave to accept it. + invoicePacket := ht.ReceiveInvoiceHtlcModification(daveAcceptor) + require.EqualValues( + ht, newOutAmountMsat, invoicePacket.ExitHtlcAmt, + ) + amtPaid := newOutAmountMsat + modifyAmount + err = daveAcceptor.Send(&invoicesrpc.HtlcModifyResponse{ + CircuitKey: invoicePacket.ExitHtlcCircuitKey, + AmtPaid: &amtPaid, + }) + require.NoError(ht, err, "dave acceptor response") + // Assert that the payment was successful. var preimage lntypes.Preimage copy(preimage[:], invoice.RPreimage) diff --git a/itest/lnd_invoice_acceptor_test.go b/itest/lnd_invoice_acceptor_test.go new file mode 100644 index 000000000..5a4a35ba0 --- /dev/null +++ b/itest/lnd_invoice_acceptor_test.go @@ -0,0 +1,358 @@ +package itest + +import ( + "context" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/lightningnetwork/lnd/chainreg" + "github.com/lightningnetwork/lnd/invoices" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnrpc/invoicesrpc" + "github.com/lightningnetwork/lnd/lnrpc/routerrpc" + "github.com/lightningnetwork/lnd/lntest" + "github.com/lightningnetwork/lnd/lntest/node" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/routing/route" + "github.com/stretchr/testify/require" +) + +// testInvoiceHtlcModifierBasic tests the basic functionality of the invoice +// HTLC modifier RPC server. +func testInvoiceHtlcModifierBasic(ht *lntest.HarnessTest) { + ts := newAcceptorTestScenario(ht) + + alice, bob, carol := ts.alice, ts.bob, ts.carol + + // Open and wait for channels. + const chanAmt = btcutil.Amount(300000) + p := lntest.OpenChannelParams{Amt: chanAmt} + reqs := []*lntest.OpenChannelRequest{ + {Local: alice, Remote: bob, Param: p}, + {Local: bob, Remote: carol, Param: p}, + } + resp := ht.OpenMultiChannelsAsync(reqs) + cpAB, cpBC := resp[0], resp[1] + + // Make sure Alice is aware of channel Bob=>Carol. + ht.AssertTopologyChannelOpen(alice, cpBC) + + // Initiate Carol's invoice HTLC modifier. + invoiceModifier, cancelModifier := carol.RPC.InvoiceHtlcModifier() + + // We need to wait a bit to make sure the gRPC stream is established + // correctly. + time.Sleep(50 * time.Millisecond) + + // Make sure we get an error if we try to register a second modifier and + // then try to use it (the error won't be returned on connect, only on + // the first _read_ interaction on the stream). + mod2, err := carol.RPC.Invoice.HtlcModifier(context.Background()) + require.NoError(ht, err) + _, err = mod2.Recv() + require.ErrorContains( + ht, err, + invoices.ErrInterceptorClientAlreadyConnected.Error(), + ) + + // We also add a normal (forwarding) HTLC interceptor at Bob, so we can + // test that custom wire messages on the HTLC are forwarded correctly to + // the invoice HTLC interceptor. + bobInterceptor, bobInterceptorCancel := bob.RPC.HtlcInterceptor() + + // Prepare the test cases. + testCases := ts.prepareTestCases() + + for tcIdx, tc := range testCases { + ht.Logf("Running test case: %d", tcIdx) + + // Initiate a payment from Alice to Carol in a separate + // goroutine. We use a separate goroutine to avoid blocking the + // main goroutine where we will make use of the invoice + // acceptor. + sendPaymentDone := make(chan struct{}) + go func() { + // Signal that all the payments have been sent. + defer close(sendPaymentDone) + + _ = ts.sendPayment(tc) + }() + + // First, intercept the HTLC at Bob. + packet := ht.ReceiveHtlcInterceptor(bobInterceptor) + err := bobInterceptor.Send( + &routerrpc.ForwardHtlcInterceptResponse{ + IncomingCircuitKey: packet.IncomingCircuitKey, + OutAmountMsat: packet.OutgoingAmountMsat, + OutWireCustomRecords: tc.lastHopCustomRecords, + Action: actionResumeModify, + }, + ) + require.NoError(ht, err, "failed to send request") + + modifierRequest := ht.ReceiveInvoiceHtlcModification( + invoiceModifier, + ) + + // Sanity check the modifier request. + require.EqualValues( + ht, tc.invoiceAmountMsat, + modifierRequest.Invoice.ValueMsat, + ) + require.EqualValues( + ht, tc.sendAmountMsat, modifierRequest.ExitHtlcAmt, + ) + require.Equal( + ht, tc.lastHopCustomRecords, + modifierRequest.ExitHtlcWireCustomRecords, + ) + + // For all other packets we resolve according to the test case. + amtPaid := uint64(tc.invoiceAmountMsat) + err = invoiceModifier.Send( + &invoicesrpc.HtlcModifyResponse{ + CircuitKey: modifierRequest.ExitHtlcCircuitKey, + AmtPaid: &amtPaid, + }, + ) + require.NoError(ht, err, "failed to send request") + + ht.Log("Waiting for payment send to complete") + select { + case <-sendPaymentDone: + ht.Log("Payment send attempt complete") + case <-time.After(defaultTimeout): + require.Fail(ht, "timeout waiting for payment send") + } + + ht.Log("Ensure invoice status is settled") + require.Eventually(ht, func() bool { + updatedInvoice := carol.RPC.LookupInvoice( + tc.invoice.RHash, + ) + + return updatedInvoice.State == tc.finalInvoiceState + }, defaultTimeout, 1*time.Second) + + updatedInvoice := carol.RPC.LookupInvoice( + tc.invoice.RHash, + ) + + require.Len(ht, updatedInvoice.Htlcs, 1) + require.Equal( + ht, tc.lastHopCustomRecords, + updatedInvoice.Htlcs[0].CustomRecords, + ) + + // Make sure the custom channel data contains the encoded + // version of the custom records. + customRecords := lnwire.CustomRecords( + updatedInvoice.Htlcs[0].CustomRecords, + ) + encodedRecords, err := customRecords.Serialize() + require.NoError(ht, err) + + require.Equal( + ht, encodedRecords, + updatedInvoice.Htlcs[0].CustomChannelData, + ) + } + + // We don't need the HTLC interceptor at Bob anymore. + bobInterceptorCancel() + + // After the normal test cases, we test that we can shut down Carol + // while an HTLC interception is going on. We initiate a payment from + // Alice to Carol in a separate goroutine. We use a separate goroutine + // to avoid blocking the main goroutine where we will make use of the + // invoice acceptor. + sendPaymentDone := make(chan struct{}) + tc := &acceptorTestCase{ + invoiceAmountMsat: 9000, + sendAmountMsat: 9000, + } + ts.createInvoice(tc) + + go func() { + // Signal that all the payments have been sent. + defer close(sendPaymentDone) + + _ = ts.sendPayment(tc) + }() + + modifierRequest := ht.ReceiveInvoiceHtlcModification(invoiceModifier) + + // Sanity check the modifier request. + require.EqualValues( + ht, tc.invoiceAmountMsat, modifierRequest.Invoice.ValueMsat, + ) + require.EqualValues( + ht, tc.sendAmountMsat, modifierRequest.ExitHtlcAmt, + ) + + // We don't send a response to the modifier, but instead shut down and + // restart Carol. + restart := ht.SuspendNode(carol) + require.NoError(ht, restart()) + + ht.Log("Waiting for payment send to complete") + select { + case <-sendPaymentDone: + ht.Log("Payment send attempt complete") + case <-time.After(defaultTimeout): + require.Fail(ht, "timeout waiting for payment send") + } + + cancelModifier() + + // Finally, close channels. + ht.CloseChannel(alice, cpAB) + ht.CloseChannel(bob, cpBC) +} + +// acceptorTestCase is a helper struct to hold test case data. +type acceptorTestCase struct { + // invoiceAmountMsat is the amount of the invoice. + invoiceAmountMsat int64 + + // sendAmountMsat is the amount that will be sent in the payment. + sendAmountMsat int64 + + // lastHopCustomRecords is a map of custom records that will be added + // to the last hop of the payment, by an HTLC interceptor at Bob. + lastHopCustomRecords map[uint64][]byte + + // finalInvoiceState is the expected eventual final state of the + // invoice. + finalInvoiceState lnrpc.Invoice_InvoiceState + + // payAddr is the payment address of the invoice. + payAddr []byte + + // invoice is the invoice that will be paid. + invoice *lnrpc.Invoice +} + +// acceptorTestScenario is a helper struct to hold the test context and provides +// helpful functionality. +type acceptorTestScenario struct { + ht *lntest.HarnessTest + alice, bob, carol *node.HarnessNode +} + +// newAcceptorTestScenario initializes a new test scenario with three nodes and +// connects them to have the following topology, +// +// Alice --> Bob --> Carol +// +// Among them, Alice and Bob are standby nodes and Carol is a new node. +func newAcceptorTestScenario(ht *lntest.HarnessTest) *acceptorTestScenario { + alice, bob := ht.Alice, ht.Bob + carol := ht.NewNode("carol", nil) + + ht.EnsureConnected(alice, bob) + ht.EnsureConnected(bob, carol) + + return &acceptorTestScenario{ + ht: ht, + alice: alice, + bob: bob, + carol: carol, + } +} + +// prepareTestCases prepares test cases. +func (c *acceptorTestScenario) prepareTestCases() []*acceptorTestCase { + cases := []*acceptorTestCase{ + // Send a payment with amount less than the invoice amount. + // Amount checking is skipped during the invoice settlement + // process. The sent payment should eventually result in the + // invoice being settled. + { + invoiceAmountMsat: 9000, + sendAmountMsat: 1000, + finalInvoiceState: lnrpc.Invoice_SETTLED, + }, + { + invoiceAmountMsat: 9000, + sendAmountMsat: 1000, + finalInvoiceState: lnrpc.Invoice_SETTLED, + lastHopCustomRecords: map[uint64][]byte{ + lnwire.MinCustomRecordsTlvType: {1, 2, 3}, + }, + }, + } + + for _, t := range cases { + c.createInvoice(t) + } + + return cases +} + +// createInvoice creates an invoice for the given test case. +func (c *acceptorTestScenario) createInvoice(tc *acceptorTestCase) { + inv := &lnrpc.Invoice{ValueMsat: tc.invoiceAmountMsat} + addResponse := c.carol.RPC.AddInvoice(inv) + invoice := c.carol.RPC.LookupInvoice(addResponse.RHash) + + // We'll need to also decode the returned invoice so we can grab the + // payment address which is now required for ALL payments. + payReq := c.carol.RPC.DecodePayReq(invoice.PaymentRequest) + + tc.invoice = invoice + tc.payAddr = payReq.PaymentAddr +} + +// buildRoute is a helper function to build a route with given hops. +func (c *acceptorTestScenario) buildRoute(amtMsat int64, + hops []*node.HarnessNode, payAddr []byte) *lnrpc.Route { + + rpcHops := make([][]byte, 0, len(hops)) + for _, hop := range hops { + k := hop.PubKeyStr + pubkey, err := route.NewVertexFromStr(k) + require.NoErrorf(c.ht, err, "error parsing %v: %v", k, err) + rpcHops = append(rpcHops, pubkey[:]) + } + + req := &routerrpc.BuildRouteRequest{ + AmtMsat: amtMsat, + FinalCltvDelta: chainreg.DefaultBitcoinTimeLockDelta, + HopPubkeys: rpcHops, + PaymentAddr: payAddr, + } + + routeResp := c.alice.RPC.BuildRoute(req) + + return routeResp.Route +} + +// sendPaymentAndAssertAction sends a payment from alice to carol. +func (c *acceptorTestScenario) sendPayment( + tc *acceptorTestCase) *lnrpc.HTLCAttempt { + + // Build a route from alice to carol. + aliceBobCarolRoute := c.buildRoute( + tc.sendAmountMsat, []*node.HarnessNode{c.bob, c.carol}, + tc.payAddr, + ) + + // We need to cheat a bit. We are attempting to pay an invoice with + // amount X with an HTLC of amount Y that is less than X. And then we + // use the invoice HTLC interceptor to simulate the HTLC actually + // carrying amount X (even though the actual HTLC transaction output + // only has amount Y). But in order for the invoice to be settled, we + // need to make sure that the MPP total amount record in the last hop + // is set to the invoice amount. This would also be the case in a normal + // MPP payment, where each shard only pays a fraction of the invoice. + aliceBobCarolRoute.Hops[1].MppRecord.TotalAmtMsat = tc.invoiceAmountMsat + + // Send the payment. + sendReq := &routerrpc.SendToRouteRequest{ + PaymentHash: tc.invoice.RHash, + Route: aliceBobCarolRoute, + } + + return c.alice.RPC.SendToRouteV2(sendReq) +}