From 82d567c6d599f5bfe040cd8b12e8a730fb4eaa56 Mon Sep 17 00:00:00 2001 From: George Tsagkarelis Date: Fri, 22 Nov 2024 18:48:43 +0100 Subject: [PATCH] itest: extend liquidity edge cases for rfq htlc tracking --- itest/assets_test.go | 66 ++++++--- itest/litd_custom_channels_test.go | 219 ++++++++++++++++++++++++++--- 2 files changed, 240 insertions(+), 45 deletions(-) diff --git a/itest/assets_test.go b/itest/assets_test.go index 97b399fe0..ad91e4b67 100644 --- a/itest/assets_test.go +++ b/itest/assets_test.go @@ -56,6 +56,10 @@ const ( DefaultPushSat int64 = 1062 ) +var ( + NoScidOpt = fn.None[rfqmsg.SerialisedScid]() +) + // createTestAssetNetwork sends asset funds from Charlie to Dave and Erin, so // they can fund asset channels with Yara and Fabia, respectively. So the asset // channels created are Charlie->Dave, Dave->Yara, Erin->Fabia. The channels @@ -714,6 +718,9 @@ func sendAssetKeySendPayment(t *testing.T, src, dst *HarnessNode, amt uint64, result, err := getAssetPaymentResult(stream, false) require.NoError(t, err) + if result.Status == lnrpc.Payment_FAILED { + t.Logf("Failure reason: %v", result.FailureReason) + } require.Equal(t, expectedStatus, result.Status) expectedReason := failReason.UnwrapOr( @@ -792,7 +799,7 @@ func createAndPayNormalInvoice(t *testing.T, src, rfqPeer, dst *HarnessNode, numUnits, _ := payInvoiceWithAssets( t, src, rfqPeer, invoiceResp.PaymentRequest, assetID, smallShards, - fn.None[lnrpc.Payment_PaymentStatus](), + fn.None[lnrpc.Payment_PaymentStatus](), NoScidOpt, ) return numUnits @@ -858,7 +865,8 @@ func payInvoiceWithSatoshiLastHop(t *testing.T, payer *HarnessNode, func payInvoiceWithAssets(t *testing.T, payer, rfqPeer *HarnessNode, payReq string, assetID []byte, smallShards bool, - expectedPayStatus fn.Option[lnrpc.Payment_PaymentStatus]) (uint64, + expectedPayStatus fn.Option[lnrpc.Payment_PaymentStatus], + manualRfq fn.Option[rfqmsg.SerialisedScid]) (uint64, rfqmath.BigIntFixedPoint) { ctxb := context.Background() @@ -886,40 +894,52 @@ func payInvoiceWithAssets(t *testing.T, payer, rfqPeer *HarnessNode, AssetId: assetID, PeerPubkey: rfqPeer.PubKey[:], PaymentRequest: sendReq, + Scid: uint64(manualRfq.UnwrapOr(0)), }) require.NoError(t, err) - // We want to receive the accepted quote message first, so we know how - // many assets we're going to pay. - quoteMsg, err := stream.Recv() - require.NoError(t, err) - acceptedQuote := quoteMsg.GetAcceptedSellOrder() - require.NotNil(t, acceptedQuote) + var ( + numUnits uint64 + rateVal rfqmath.FixedPoint[rfqmath.BigInt] + ) - peerPubKey := acceptedQuote.Peer - require.Equal(t, peerPubKey, rfqPeer.PubKeyStr) + if manualRfq.IsNone() { + // We want to receive the accepted quote message first, so we know how + // many assets we're going to pay. + quoteMsg, err := stream.Recv() + require.NoError(t, err) + acceptedQuote := quoteMsg.GetAcceptedSellOrder() + require.NotNil(t, acceptedQuote) - rpcRate := acceptedQuote.BidAssetRate - rate, err := rfqrpc.UnmarshalFixedPoint(rpcRate) - require.NoError(t, err) + peerPubKey := acceptedQuote.Peer + require.Equal(t, peerPubKey, rfqPeer.PubKeyStr) - t.Logf("Got quote for %v asset units per BTC", rate) + rpcRate := acceptedQuote.BidAssetRate + rate, err := rfqrpc.UnmarshalFixedPoint(rpcRate) + require.NoError(t, err) + + rateVal = *rate - amountMsat := lnwire.MilliSatoshi(decodedInvoice.NumMsat) - milliSatsFP := rfqmath.MilliSatoshiToUnits(amountMsat, *rate) - numUnits := milliSatsFP.ScaleTo(0).ToUint64() - msatPerUnit := float64(decodedInvoice.NumMsat) / float64(numUnits) - t.Logf("Got quote for %v asset units at %3f msat/unit from peer %s "+ - "with SCID %d", numUnits, msatPerUnit, peerPubKey, - acceptedQuote.Scid) + t.Logf("Got quote for %v asset units per BTC", rate) + + amountMsat := lnwire.MilliSatoshi(decodedInvoice.NumMsat) + milliSatsFP := rfqmath.MilliSatoshiToUnits(amountMsat, *rate) + numUnits = milliSatsFP.ScaleTo(0).ToUint64() + msatPerUnit := float64(decodedInvoice.NumMsat) / float64(numUnits) + t.Logf("Got quote for %v asset units at %3f msat/unit from peer %s "+ + "with SCID %d", numUnits, msatPerUnit, peerPubKey, + acceptedQuote.Scid) + } expectedStatus := expectedPayStatus.UnwrapOr(lnrpc.Payment_SUCCEEDED) - result, err := getAssetPaymentResult(stream, expectedPayStatus.IsSome()) + result, err := getAssetPaymentResult( + stream, expectedStatus == lnrpc.Payment_IN_FLIGHT, + ) require.NoError(t, err) require.Equal(t, expectedStatus, result.Status) - return numUnits, *rate + return numUnits, rateVal } func createAssetInvoice(t *testing.T, dstRfqPeer, dst *HarnessNode, diff --git a/itest/litd_custom_channels_test.go b/itest/litd_custom_channels_test.go index 0990d051f..5d918f88f 100644 --- a/itest/litd_custom_channels_test.go +++ b/itest/litd_custom_channels_test.go @@ -1,6 +1,7 @@ package itest import ( + "bytes" "context" "fmt" "math" @@ -15,15 +16,18 @@ import ( "github.com/lightninglabs/taproot-assets/itest" "github.com/lightninglabs/taproot-assets/proof" "github.com/lightninglabs/taproot-assets/rfqmath" + "github.com/lightninglabs/taproot-assets/rfqmsg" "github.com/lightninglabs/taproot-assets/tapchannel" "github.com/lightninglabs/taproot-assets/taprpc" "github.com/lightninglabs/taproot-assets/taprpc/mintrpc" + "github.com/lightninglabs/taproot-assets/taprpc/rfqrpc" tchrpc "github.com/lightninglabs/taproot-assets/taprpc/tapchannelrpc" "github.com/lightninglabs/taproot-assets/taprpc/universerpc" "github.com/lightninglabs/taproot-assets/tapscript" "github.com/lightningnetwork/lnd/fn" "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnrpc/invoicesrpc" + "github.com/lightningnetwork/lnd/lnrpc/routerrpc" "github.com/lightningnetwork/lnd/lnrpc/walletrpc" "github.com/lightningnetwork/lnd/lntest" "github.com/lightningnetwork/lnd/lntest/port" @@ -47,7 +51,7 @@ var ( shortTimeout = time.Second * 5 - defaultPaymentStatus = fn.None[lnrpc.Payment_PaymentStatus]() + defaultPaymentStatusOpt = fn.None[lnrpc.Payment_PaymentStatus]() ) var ( @@ -227,7 +231,7 @@ func testCustomChannelsLarge(_ context.Context, net *NetworkHarness, ) payInvoiceWithAssets( t.t, charlie, dave, invoiceResp.PaymentRequest, assetID, false, - defaultPaymentStatus, + defaultPaymentStatusOpt, NoScidOpt, ) logBalance(t.t, nodes, assetID, "after invoice") @@ -242,7 +246,7 @@ func testCustomChannelsLarge(_ context.Context, net *NetworkHarness, payInvoiceWithAssets( t.t, fabia, erin, invoiceResp2.PaymentRequest, assetID, false, - defaultPaymentStatus, + defaultPaymentStatusOpt, NoScidOpt, ) logBalance(t.t, nodes, assetID, "after invoice 2") @@ -253,7 +257,7 @@ func testCustomChannelsLarge(_ context.Context, net *NetworkHarness, ) payInvoiceWithAssets( t.t, charlie, dave, invoiceResp3.PaymentRequest, assetID, false, - defaultPaymentStatus, + defaultPaymentStatusOpt, NoScidOpt, ) logBalance(t.t, nodes, assetID, "after invoice 3") @@ -445,7 +449,7 @@ func testCustomChannels(_ context.Context, net *NetworkHarness, ) payInvoiceWithAssets( t.t, dave, charlie, invoiceResp.PaymentRequest, assetID, true, - defaultPaymentStatus, + defaultPaymentStatusOpt, NoScidOpt, ) logBalance(t.t, nodes, assetID, "after invoice back") @@ -510,7 +514,7 @@ func testCustomChannels(_ context.Context, net *NetworkHarness, ) payInvoiceWithAssets( t.t, charlie, dave, invoiceResp.PaymentRequest, assetID, true, - defaultPaymentStatus, + defaultPaymentStatusOpt, NoScidOpt, ) logBalance(t.t, nodes, assetID, "after invoice") @@ -555,7 +559,7 @@ func testCustomChannels(_ context.Context, net *NetworkHarness, ) payInvoiceWithAssets( t.t, charlie, dave, invoiceResp.PaymentRequest, assetID, true, - defaultPaymentStatus, + defaultPaymentStatusOpt, NoScidOpt, ) logBalance(t.t, nodes, assetID, "after invoice") @@ -592,7 +596,7 @@ func testCustomChannels(_ context.Context, net *NetworkHarness, ) payInvoiceWithAssets( t.t, charlie, dave, invoiceResp.PaymentRequest, assetID, true, - defaultPaymentStatus, + defaultPaymentStatusOpt, NoScidOpt, ) logBalance(t.t, nodes, assetID, "after invoice") @@ -613,7 +617,7 @@ func testCustomChannels(_ context.Context, net *NetworkHarness, ) payInvoiceWithAssets( t.t, charlie, dave, invoiceResp.PaymentRequest, assetID, true, - defaultPaymentStatus, + defaultPaymentStatusOpt, NoScidOpt, ) logBalance(t.t, nodes, assetID, "after asset-to-asset") @@ -949,7 +953,7 @@ func testCustomChannelsGroupedAsset(_ context.Context, net *NetworkHarness, ) payInvoiceWithAssets( t.t, charlie, dave, invoiceResp.PaymentRequest, assetID, true, - defaultPaymentStatus, + defaultPaymentStatusOpt, NoScidOpt, ) logBalance(t.t, nodes, assetID, "after invoice") @@ -987,7 +991,7 @@ func testCustomChannelsGroupedAsset(_ context.Context, net *NetworkHarness, ) payInvoiceWithAssets( t.t, charlie, dave, invoiceResp.PaymentRequest, assetID, true, - defaultPaymentStatus, + defaultPaymentStatusOpt, NoScidOpt, ) logBalance(t.t, nodes, assetID, "after invoice") @@ -1024,7 +1028,7 @@ func testCustomChannelsGroupedAsset(_ context.Context, net *NetworkHarness, ) payInvoiceWithAssets( t.t, charlie, dave, invoiceResp.PaymentRequest, assetID, true, - defaultPaymentStatus, + defaultPaymentStatusOpt, NoScidOpt, ) logBalance(t.t, nodes, assetID, "after invoice") @@ -1045,7 +1049,7 @@ func testCustomChannelsGroupedAsset(_ context.Context, net *NetworkHarness, ) payInvoiceWithAssets( t.t, charlie, dave, invoiceResp.PaymentRequest, assetID, true, - defaultPaymentStatus, + defaultPaymentStatusOpt, NoScidOpt, ) logBalance(t.t, nodes, assetID, "after asset-to-asset") @@ -1805,7 +1809,7 @@ func testCustomChannelsLiquidityEdgeCases(_ context.Context, SatPerVByte: 5, }, ) - defer closeChannelAndAssert(t, net, dave, channelOp, false) + defer closeChannelAndAssert(t, net, dave, channelOp, true) // This is the only public channel, we need everyone to be aware of it. assertChannelKnown(t.t, charlie, channelOp) @@ -1958,7 +1962,7 @@ func testCustomChannelsLiquidityEdgeCases(_ context.Context, payInvoiceWithAssets( t.t, charlie, dave, invoiceResp.PaymentRequest, assetID, false, - defaultPaymentStatus, + defaultPaymentStatusOpt, NoScidOpt, ) logBalance(t.t, nodes, assetID, "after big asset payment (asset "+ @@ -2004,7 +2008,7 @@ func testCustomChannelsLiquidityEdgeCases(_ context.Context, payInvoiceWithAssets( t.t, yara, dave, invoiceResp.PaymentRequest, assetID, false, - defaultPaymentStatus, + defaultPaymentStatusOpt, NoScidOpt, ) logBalance(t.t, nodes, assetID, "after big asset payment (asset "+ @@ -2020,10 +2024,11 @@ func testCustomChannelsLiquidityEdgeCases(_ context.Context, logBalance(t.t, nodes, assetID, "after small payment (asset "+ "invoice, <354sats)") - // Edge case: Now Charlie creates an asset invoice to be paid for by + // Edge case: Now Dave creates an asset invoice to be paid for by // Yara with satoshi. For the last hop we try to settle the invoice in - // satoshi, where we will check whether Charlie's strict forwarding - // works as expected. + // satoshi, where we will check whether Dave's strict forwarding works + // as expected. Charlie is only used as a dummy RFQ peer in this case, + // Yara totally ignored the RFQ hint and pays agnostically with sats. invoiceResp = createAssetInvoice( t.t, charlie, dave, 1, assetID, ) @@ -2046,6 +2051,176 @@ func testCustomChannelsLiquidityEdgeCases(_ context.Context, logBalance(t.t, nodes, assetID, "after failed payment (asset "+ "invoice, strict forwarding)") + + // Edge case: Check if the RFQ HTLC tracking accounts for cancelled + // htlcs. We achieve this by manually creating & using an RFQ quote with + // a set max amount. We first pay to a hodl invoice that we eventually + // cancel, then pay to a normal invoice which should succeed. + + // We start by sloshing some funds in the Erin<->Fabia + sendAssetKeySendPayment( + t.t, erin, fabia, 100_000, assetID, + fn.None[int64](), lnrpc.Payment_SUCCEEDED, + fn.None[lnrpc.PaymentFailureReason](), + ) + sendKeySendPayment(t.t, erin, fabia, 20_000) + + logBalance(t.t, nodes, assetID, "balance after 1st slosh") + + // We create the RFQ order. We set the max amt to ~180k sats which is + // going to evaluate to about 10k assets. + resQ, err := charlieTap.RfqClient.AddAssetSellOrder( + ctxb, &rfqrpc.AddAssetSellOrderRequest{ + AssetSpecifier: &rfqrpc.AssetSpecifier{ + Id: &rfqrpc.AssetSpecifier_AssetId{ + AssetId: assetID, + }, + }, + PaymentMaxAmt: 180_000_000, + Expiry: uint64(time.Now().Add(time.Hour).Unix()), + PeerPubKey: dave.PubKey[:], + TimeoutSeconds: 100, + }, + ) + require.NoError(t.t, err) + + // We now create a hodl invoice on Fabia, for 10k assets. + hodlInv := createAssetHodlInvoice(t.t, erin, fabia, 10_000, assetID) + + // Charlie tries to pay via Dave, by providing the scid that was + // manually created above. + scid := rfqmsg.SerialisedScid(resQ.GetAcceptedQuote().Scid) + payInvoiceWithAssets( + t.t, charlie, dave, hodlInv.payReq, assetID, true, + fn.Some(lnrpc.Payment_IN_FLIGHT), fn.Some(scid), + ) + + time.Sleep(time.Second) + + // We now assert that the expected numbers of HTLCs are present on each + // node. + // Reminder, Topology looks like this: + // + // Charlie <-> Dave <-> Erin <-> Fabia + // + // Therefore the routing nodes should have double the number of HTLCs + // required for the payment present. + assertNumHtlcs(t.t, charlie, 3) + assertNumHtlcs(t.t, dave, 6) + assertNumHtlcs(t.t, erin, 6) + assertNumHtlcs(t.t, fabia, 3) + + // Now let's cancel the invoice on Fabia. + payHash := hodlInv.preimage.Hash() + _, err = fabia.InvoicesClient.CancelInvoice( + ctxb, &invoicesrpc.CancelInvoiceMsg{ + PaymentHash: payHash[:], + }, + ) + require.NoError(t.t, err) + + // There should be no HTLCs present on any channel. + assertNumHtlcs(t.t, charlie, 0) + assertNumHtlcs(t.t, dave, 0) + assertNumHtlcs(t.t, erin, 0) + assertNumHtlcs(t.t, fabia, 0) + + // Now Fabia creates the normal invoice. + invoiceResp = createAssetInvoice( + t.t, erin, fabia, 10_000, assetID, + ) + + // Now Charlie pays the invoice, again by using the manually created + // scid. This payment should succeed. + payInvoiceWithAssets(t.t, charlie, dave, invoiceResp.PaymentRequest, + assetID, true, defaultPaymentStatusOpt, fn.Some(scid)) + + logBalance(t.t, nodes, assetID, "after manual rfq hodl") + + // Edge case: Charlie negotiates a quote with Dave which has a low max + // amount (~170k sats). Then Charlie creates an invoice with a total + // amount slightly larger than the max allowed in the quote (200k sats). + // Erin will try to pay that invoice with sats, in shards of max size + // 80k sats. Dave will eventually stop forwarding HTLCs as the RFQ HTLC + // tracking mechanism should stop them from being forwarded, as they + // violate the maximum allowed amount of the quote. + + // Charlie starts by negotiating the quote. + res, err := charlieTap.RfqClient.AddAssetBuyOrder( + ctxb, &rfqrpc.AddAssetBuyOrderRequest{ + AssetSpecifier: &rfqrpc.AssetSpecifier{ + Id: &rfqrpc.AssetSpecifier_AssetId{ + AssetId: assetID, + }, + }, + AssetMaxAmt: 10_000, + Expiry: uint64(time.Now().Add(time.Hour).Unix()), + PeerPubKey: dave.PubKey[:], + TimeoutSeconds: 10, + }, + ) + require.NoError(t.t, err) + + quote, ok := res.Response.(*rfqrpc.AddAssetBuyOrderResponse_AcceptedQuote) + require.True(t.t, ok) + + // We now manually add the invoice in order to inject the above, + // manually generated, quote. + iResp, err := charlie.AddInvoice(ctxb, &lnrpc.Invoice{ + Memo: "", + Value: 200_000, + RPreimage: bytes.Repeat([]byte{11}, 32), + CltvExpiry: 60, + RouteHints: []*lnrpc.RouteHint{ + &lnrpc.RouteHint{ + HopHints: []*lnrpc.HopHint{ + &lnrpc.HopHint{ + NodeId: dave.PubKeyStr, + ChanId: quote.AcceptedQuote.Scid, + }, + }, + }, + }, + }) + require.NoError(t.t, err) + + // Now Erin tries to pay the invoice. Since the multipart payment will + // have some of its shards failing the pathfinding logic will keep going + // and we won't see a payment failure but a timeout. If a final outcome + // is not produced within a reasonable amount of time, we assume the + // payment is still trying to find a route, therefore the HTLC rejection + // works. + timeoutChan = time.After(PaymentTimeout / 2) + done = make(chan bool, 1) + + ctxc, cancel := context.WithCancel(context.Background()) + + //nolint:lll + go func() { + // payInvoiceWithSatoshi(t.t, erin, iResp, lnrpc.Payment_FAILED) + sendReq := &routerrpc.SendPaymentRequest{ + PaymentRequest: iResp.PaymentRequest, + TimeoutSeconds: int32(PaymentTimeout.Seconds()), + MaxShardSizeMsat: 80_000_000, + FeeLimitMsat: 1_000_000, + } + stream, err := erin.RouterClient.SendPaymentV2(ctxc, sendReq) + if err == nil { + _, _ = getPaymentResult(stream) + } + + done <- true + }() + + select { + case <-done: + t.Fatalf("Payment should not produce a final outcome") + + case <-timeoutChan: + cancel() + } + + logBalance(t.t, nodes, assetID, "after small manual rfq") } // testCustomChannelsBalanceConsistency is a test that test the balance of nodes @@ -2563,7 +2738,7 @@ func testCustomChannelsOraclePricing(_ context.Context, numUnits, rate := payInvoiceWithAssets( t.t, charlie, dave, invoiceResp.PaymentRequest, assetID, false, - defaultPaymentStatus, + defaultPaymentStatusOpt, NoScidOpt, ) logBalance(t.t, nodes, assetID, "after invoice") @@ -2911,13 +3086,13 @@ func runCustomChannelsHtlcForceClose(ctxb context.Context, t *harnessTest, for _, aliceInvoice := range aliceHodlInvoices { payInvoiceWithAssets( t.t, bob, alice, aliceInvoice.payReq, assetID, mpp, - fn.Some(lnrpc.Payment_IN_FLIGHT), + fn.Some(lnrpc.Payment_IN_FLIGHT), NoScidOpt, ) } for _, bobInvoice := range bobHodlInvoices { payInvoiceWithAssets( t.t, alice, bob, bobInvoice.payReq, assetID, false, - fn.Some(lnrpc.Payment_IN_FLIGHT), + fn.Some(lnrpc.Payment_IN_FLIGHT), NoScidOpt, ) }