From f0558babf374e8a8cd01e70a07ab40965daa55cf Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Tue, 7 May 2024 12:24:55 +0200 Subject: [PATCH] multi: send MPP payment to blinded path Make various sender side adjustments so that a sender is able to send an MP payment to a single blinded path without actually including an MPP record in the payment. --- channeldb/payment_control.go | 58 ++++++++++- itest/list_on_test.go | 4 + itest/lnd_route_blinding_test.go | 173 +++++++++++++++++++++++++++++++ routing/payment_session.go | 11 +- 4 files changed, 238 insertions(+), 8 deletions(-) diff --git a/channeldb/payment_control.go b/channeldb/payment_control.go index bd83e32cc..0eadf4b1f 100644 --- a/channeldb/payment_control.go +++ b/channeldb/payment_control.go @@ -67,12 +67,12 @@ var ( // ErrValueMismatch is returned if we try to register a non-MPP attempt // with an amount that doesn't match the payment amount. - ErrValueMismatch = errors.New("attempted value doesn't match payment" + + ErrValueMismatch = errors.New("attempted value doesn't match payment " + "amount") // ErrValueExceedsAmt is returned if we try to register an attempt that // would take the total sent amount above the payment amount. - ErrValueExceedsAmt = errors.New("attempted value exceeds payment" + + ErrValueExceedsAmt = errors.New("attempted value exceeds payment " + "amount") // ErrNonMPPayment is returned if we try to register an MPP attempt for @@ -83,6 +83,17 @@ var ( // a payment that already has an MPP attempt registered. ErrMPPayment = errors.New("payment has MPP attempts") + // ErrMPPRecordInBlindedPayment is returned if we try to register an + // attempt with an MPP record for a payment to a blinded path. + ErrMPPRecordInBlindedPayment = errors.New("blinded payment cannot " + + "contain MPP records") + + // ErrBlindedPaymentTotalAmountMismatch is returned if we try to + // register an HTLC shard to a blinded route where the total amount + // doesn't match existing shards. + ErrBlindedPaymentTotalAmountMismatch = errors.New("blinded path " + + "total amount mismatch") + // ErrMPPPaymentAddrMismatch is returned if we try to register an MPP // shard where the payment address doesn't match existing shards. ErrMPPPaymentAddrMismatch = errors.New("payment address mismatch") @@ -96,7 +107,7 @@ var ( // attempt to a payment that has at least one of its HTLCs settled. ErrPaymentPendingSettled = errors.New("payment has settled htlcs") - // ErrPaymentAlreadyFailed is returned when we try to add a new attempt + // ErrPaymentPendingFailed is returned when we try to add a new attempt // to a payment that already has a failure reason. ErrPaymentPendingFailed = errors.New("payment has failure reason") @@ -334,12 +345,48 @@ func (p *PaymentControl) RegisterAttempt(paymentHash lntypes.Hash, return err } + // If the final hop has encrypted data, then we know this is a + // blinded payment. In blinded payments, MPP records are not set + // for split payments and the recipient is responsible for using + // a consistent PathID across the various encrypted data + // payloads that we received from them for this payment. All we + // need to check is that the total amount field for each HTLC + // in the split payment is correct. + isBlinded := len(attempt.Route.FinalHop().EncryptedData) != 0 + // Make sure any existing shards match the new one with regards // to MPP options. mpp := attempt.Route.FinalHop().MPP + + // MPP records should not be set for attempts to blinded paths. + if isBlinded && mpp != nil { + return ErrMPPRecordInBlindedPayment + } + for _, h := range payment.InFlightHTLCs() { hMpp := h.Route.FinalHop().MPP + // If this is a blinded payment, then no existing HTLCs + // should have MPP records. + if isBlinded && hMpp != nil { + return ErrMPPRecordInBlindedPayment + } + + // If this is a blinded payment, then we just need to + // check that the TotalAmtMsat field for this shard + // is equal to that of any other shard in the same + // payment. + if isBlinded { + if attempt.Route.FinalHop().TotalAmtMsat != + h.Route.FinalHop().TotalAmtMsat { + + //nolint:lll + return ErrBlindedPaymentTotalAmountMismatch + } + + continue + } + switch { // We tried to register a non-MPP attempt for a MPP // payment. @@ -367,9 +414,10 @@ func (p *PaymentControl) RegisterAttempt(paymentHash lntypes.Hash, } // If this is a non-MPP attempt, it must match the total amount - // exactly. + // exactly. Note that a blinded payment is considered an MPP + // attempt. amt := attempt.Route.ReceiverAmt() - if mpp == nil && amt != payment.Info.Value { + if !isBlinded && mpp == nil && amt != payment.Info.Value { return ErrValueMismatch } diff --git a/itest/list_on_test.go b/itest/list_on_test.go index 0ef375bd2..9938ac85f 100644 --- a/itest/list_on_test.go +++ b/itest/list_on_test.go @@ -586,6 +586,10 @@ var allTestCases = []*lntest.TestCase{ Name: "on chain to blinded", TestFunc: testErrorHandlingOnChainFailure, }, + { + Name: "mpp to single blinded path", + TestFunc: testMPPToSingleBlindedPath, + }, { Name: "removetx", TestFunc: testRemoveTx, diff --git a/itest/lnd_route_blinding_test.go b/itest/lnd_route_blinding_test.go index 7204819dc..96c338d2f 100644 --- a/itest/lnd_route_blinding_test.go +++ b/itest/lnd_route_blinding_test.go @@ -878,3 +878,176 @@ func testErrorHandlingOnChainFailure(ht *lntest.HarnessTest) { ht.CloseChannel(testCase.carol, testCase.channels[2]) testCase.cancel() } + +// testMPPToSingleBlindedPath tests that a two-shard MPP payment can be sent +// over a single blinded path. +// The following graph is created where Dave is the destination node, and he +// will choose Carol as the introduction node. The channel capacities are set in +// such a way that Alice will have to split the payment to dave over both the +// A->B->C-D and A->E->C->D routes. +// +// ---- Bob --- +// / \ +// Alice Carol --- Dave +// \ / +// ---- Eve --- +func testMPPToSingleBlindedPath(ht *lntest.HarnessTest) { + // Create a five-node context consisting of Alice, Bob and three new + // nodes. + alice, bob := ht.Alice, ht.Bob + + // Restrict Dave so that he only ever chooses the Carol->Dave path for + // a blinded route. + dave := ht.NewNode("dave", []string{ + "--invoices.blinding.min-num-real-hops=1", + "--invoices.blinding.num-hops=1", + }) + carol := ht.NewNode("carol", nil) + eve := ht.NewNode("eve", nil) + + // Connect nodes to ensure propagation of channels. + ht.EnsureConnected(alice, bob) + ht.EnsureConnected(alice, eve) + ht.EnsureConnected(carol, bob) + ht.EnsureConnected(carol, eve) + ht.EnsureConnected(carol, dave) + + // Send coins to the nodes and mine 1 blocks to confirm them. + for i := 0; i < 2; i++ { + ht.FundCoinsUnconfirmed(btcutil.SatoshiPerBitcoin, carol) + ht.FundCoinsUnconfirmed(btcutil.SatoshiPerBitcoin, dave) + ht.FundCoinsUnconfirmed(btcutil.SatoshiPerBitcoin, eve) + ht.MineBlocksAndAssertNumTxes(1, 3) + } + + const paymentAmt = btcutil.Amount(300000) + + nodes := []*node.HarnessNode{alice, bob, carol, dave, eve} + reqs := []*lntest.OpenChannelRequest{ + { + Local: alice, + Remote: bob, + Param: lntest.OpenChannelParams{ + Amt: paymentAmt * 2 / 3, + }, + }, + { + Local: alice, + Remote: eve, + Param: lntest.OpenChannelParams{ + Amt: paymentAmt * 2 / 3, + }, + }, + { + Local: bob, + Remote: carol, + Param: lntest.OpenChannelParams{ + Amt: paymentAmt * 2, + }, + }, + { + Local: eve, + Remote: carol, + Param: lntest.OpenChannelParams{ + Amt: paymentAmt * 2, + }, + }, + { + Local: carol, + Remote: dave, + Param: lntest.OpenChannelParams{ + Amt: paymentAmt * 2, + }, + }, + } + + channelPoints := ht.OpenMultiChannelsAsync(reqs) + + // Make sure every node has heard about every channel. + for _, hn := range nodes { + for _, cp := range channelPoints { + ht.AssertTopologyChannelOpen(hn, cp) + } + + // Each node should have exactly 5 edges. + ht.AssertNumEdges(hn, len(channelPoints), false) + } + + // Make Dave create an invoice with a blinded path for Alice to pay. + invoice := &lnrpc.Invoice{ + Memo: "test", + Value: int64(paymentAmt), + Blind: true, + } + invoiceResp := dave.RPC.AddInvoice(invoice) + + sendReq := &routerrpc.SendPaymentRequest{ + PaymentRequest: invoiceResp.PaymentRequest, + MaxParts: 10, + TimeoutSeconds: 60, + FeeLimitMsat: noFeeLimitMsat, + } + payment := ht.SendPaymentAssertSettled(alice, sendReq) + + preimageBytes, err := hex.DecodeString(payment.PaymentPreimage) + require.NoError(ht, err) + + preimage, err := lntypes.MakePreimage(preimageBytes) + require.NoError(ht, err) + + hash, err := lntypes.MakeHash(invoiceResp.RHash) + require.NoError(ht, err) + + // Make sure we got the preimage. + require.True(ht, preimage.Matches(hash), "preimage doesn't match") + + // Check that Alice split the payment in at least two shards. Because + // the hand-off of the htlc to the link is asynchronous (via a mailbox), + // there is some non-determinism in the process. Depending on whether + // the new pathfinding round is started before or after the htlc is + // locked into the channel, different sharding may occur. Therefore, we + // can only check if the number of shards isn't below the theoretical + // minimum. + succeeded := 0 + for _, htlc := range payment.Htlcs { + if htlc.Status == lnrpc.HTLCAttempt_SUCCEEDED { + succeeded++ + } + } + + const minExpectedShards = 2 + require.GreaterOrEqual(ht, succeeded, minExpectedShards, + "expected shards not reached") + + // Make sure Dave show the invoice as settled for the full amount. + inv := dave.RPC.LookupInvoice(invoiceResp.RHash) + + require.EqualValues(ht, paymentAmt, inv.AmtPaidSat, + "incorrect payment amt") + + require.Equal(ht, lnrpc.Invoice_SETTLED, inv.State, + "Invoice not settled") + + settled := 0 + for _, htlc := range inv.Htlcs { + if htlc.State == lnrpc.InvoiceHTLCState_SETTLED { + settled++ + } + } + require.Equal(ht, succeeded, settled, "num of HTLCs wrong") + + // Close all channels without mining the closing transactions. + ht.CloseChannelAssertPending(alice, channelPoints[0], false) + ht.CloseChannelAssertPending(alice, channelPoints[1], false) + ht.CloseChannelAssertPending(bob, channelPoints[2], false) + ht.CloseChannelAssertPending(eve, channelPoints[3], false) + ht.CloseChannelAssertPending(carol, channelPoints[4], false) + + // Now mine a block to include all the closing transactions. + ht.MineBlocksAndAssertNumTxes(1, 5) + + // Assert that the channels are closed. + for _, hn := range nodes { + ht.AssertNumWaitingClose(hn, 0) + } +} diff --git a/routing/payment_session.go b/routing/payment_session.go index 14b369407..f320ce0dc 100644 --- a/routing/payment_session.go +++ b/routing/payment_session.go @@ -338,8 +338,12 @@ func (p *paymentSession) RequestRoute(maxAmt, feeLimit lnwire.MilliSatoshi, switch { case err == errNoPathFound: // Don't split if this is a legacy payment without mpp - // record. - if p.payment.PaymentAddr == nil { + // record. If it has a blinded path though, then we + // can split. Split payments to blinded paths won't have + // MPP records. + if p.payment.PaymentAddr == nil && + p.payment.BlindedPayment == nil { + p.log.Debugf("not splitting because payment " + "address is unspecified") @@ -357,7 +361,8 @@ func (p *paymentSession) RequestRoute(maxAmt, feeLimit lnwire.MilliSatoshi, !destFeatures.HasFeature(lnwire.AMPOptional) { p.log.Debug("not splitting because " + - "destination doesn't declare MPP or AMP") + "destination doesn't declare MPP or " + + "AMP") return nil, errNoPathFound }