diff --git a/itest/list_on_test.go b/itest/list_on_test.go index 13d7814a3..69fdf6787 100644 --- a/itest/list_on_test.go +++ b/itest/list_on_test.go @@ -257,6 +257,10 @@ var allTestCases = []*lntest.TestCase{ Name: "multi-hop payments", TestFunc: testMultiHopPayments, }, + { + Name: "estimate route fee", + TestFunc: testEstimateRouteFee, + }, { Name: "anchors reserved value", TestFunc: testAnchorReservedValue, diff --git a/itest/lnd_estimate_route_fee_test.go b/itest/lnd_estimate_route_fee_test.go new file mode 100644 index 000000000..352dfe5ce --- /dev/null +++ b/itest/lnd_estimate_route_fee_test.go @@ -0,0 +1,405 @@ +package itest + +import ( + "testing" + + "github.com/btcsuite/btcd/btcutil" + "github.com/lightningnetwork/lnd/chainreg" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnrpc/routerrpc" + "github.com/lightningnetwork/lnd/lntest" + "github.com/lightningnetwork/lnd/lntest/node" + "github.com/lightningnetwork/lnd/routing" + "github.com/stretchr/testify/require" +) + +var ( + probeInitiator *node.HarnessNode + probeAmount = btcutil.Amount(100_000) + probeAmt = int64(probeAmount) * 1_000 + + failureReasonNone = lnrpc.PaymentFailureReason_FAILURE_REASON_NONE + failureReasonNoRoute = lnrpc.PaymentFailureReason_FAILURE_REASON_NO_ROUTE //nolint:lll +) + +const ( + ErrNoRouteInGraph = "unable to find a path to destination" +) + +type estimateRouteFeeTestCase struct { + // name is the name of the target test case. + name string + + // probing is a flag that indicates whether the test case estimates fees + // using the graph or by probing. + probing bool + + // destination is the node that will receive the probe. + destination *node.HarnessNode + + // routeHints are the route hints that will be used for the probe. + routeHints []*lnrpc.RouteHint + + // expectedRoutingFeesMsat are the expected routing fees that will be + // returned by the probe. + expectedRoutingFeesMsat int64 + + // expectedCltvDelta is the expected cltv delta that will be returned by + // the fee estimation. + expectedCltvDelta int64 + + // expectedFailureReason is the expected payment failure reason that + // will be returned by the probe. + expectedFailureReason lnrpc.PaymentFailureReason + + // expectedError are the expected error that will be returned if the + // probing fails. + expectedError string +} + +// testEstimateRouteFee tests the estimation of routing fees using either graph +// data or sending out a probe payment. +func testEstimateRouteFee(ht *lntest.HarnessTest) { + mts := newMppTestScenario(ht) + + // We extend the regular mpp test scenario with a new node Paula. Paula + // is connected to Bob and Eve through private channels. + // /-------------\ + // _ Eve _ (private) \ + // / \ \ + // Alice -- Carol ---- Bob --------- Paula + // \ / (private) + // \__ Dave ____/ + // + req := &mppOpenChannelRequest{ + amtAliceCarol: 200_000, + amtAliceDave: 200_000, + amtCarolBob: 200_000, + amtCarolEve: 200_000, + amtDaveBob: 200_000, + amtEveBob: 200_000, + } + mts.openChannels(req) + chanPointDaveBob := mts.channelPoints[4] + chanPointEveBob := mts.channelPoints[5] + + // Alice will initiate all probe payments. + probeInitiator = mts.alice + + paula := ht.NewNode("Paula", nil) + + // The channel from Bob to Paula actually doesn't have enough liquidity + // to carry out the probe. We assume in normal operation that hop hints + // added to the invoice always have enough liquidity, but here we check + // that the prober uses the more expensive route. + ht.EnsureConnected(mts.bob, paula) + channelPointBobPaula := ht.OpenChannel( + mts.bob, paula, lntest.OpenChannelParams{ + Private: true, + Amt: 90_000, + PushAmt: 69_000, + }, + ) + + ht.EnsureConnected(mts.eve, paula) + channelPointEvePaula := ht.OpenChannel( + mts.eve, paula, lntest.OpenChannelParams{ + Private: true, + Amt: 1_000_000, + }, + ) + + bobsPrivChannels := ht.Bob.RPC.ListChannels(&lnrpc.ListChannelsRequest{ + PrivateOnly: true, + }) + require.Len(ht, bobsPrivChannels.Channels, 1) + bobPaulaChanID := bobsPrivChannels.Channels[0].ChanId + + evesPrivChannels := mts.eve.RPC.ListChannels(&lnrpc.ListChannelsRequest{ + PrivateOnly: true, + }) + require.Len(ht, evesPrivChannels.Channels, 1) + evePaulaChanID := evesPrivChannels.Channels[0].ChanId + + // Let's disable the paths from Alice to Bob through Dave and Eve with + // high fees. This ensures that the path estimates are based on Carol's + // channel to Bob for the first set of tests. + expectedPolicy := &lnrpc.RoutingPolicy{ + FeeBaseMsat: 200_000, + FeeRateMilliMsat: int64(0.001 * 1_000_000), + TimeLockDelta: 40, + MinHtlc: 1000, // default value + MaxHtlcMsat: 133_650_000, + } + mts.dave.UpdateGlobalPolicy(expectedPolicy) + ht.AssertChannelPolicyUpdate( + mts.alice, mts.dave, expectedPolicy, chanPointDaveBob, false, + ) + expectedPolicy.FeeBaseMsat = 500_000 + mts.eve.UpdateGlobalPolicy(expectedPolicy) + ht.AssertChannelPolicyUpdate( + mts.alice, mts.eve, expectedPolicy, chanPointEveBob, false, + ) + + var ( + bobHopHint = &lnrpc.HopHint{ + NodeId: mts.bob.PubKeyStr, + FeeBaseMsat: 1_000, + FeeProportionalMillionths: 1, + CltvExpiryDelta: 100, + ChanId: bobPaulaChanID, + } + + bobExpHint = &lnrpc.HopHint{ + NodeId: mts.bob.PubKeyStr, + FeeBaseMsat: 2000, + FeeProportionalMillionths: 2000, + CltvExpiryDelta: 80, + ChanId: bobPaulaChanID, + } + + eveHopHint = &lnrpc.HopHint{ + NodeId: mts.eve.PubKeyStr, + FeeBaseMsat: 1_000_000, + FeeProportionalMillionths: 1, + CltvExpiryDelta: 200, + ChanId: evePaulaChanID, + } + + singleRouteHint = []*lnrpc.RouteHint{ + { + HopHints: []*lnrpc.HopHint{ + bobHopHint, + }, + }, + } + + lspDifferentFeesHints = []*lnrpc.RouteHint{ + { + HopHints: []*lnrpc.HopHint{ + bobHopHint, + }, + }, + { + HopHints: []*lnrpc.HopHint{ + bobExpHint, + }, + }, + } + + nonLspProbingRouteHints = []*lnrpc.RouteHint{ + { + HopHints: []*lnrpc.HopHint{ + eveHopHint, + }, + }, + { + HopHints: []*lnrpc.HopHint{ + bobHopHint, + }, + }, + } + ) + + defaultTimelock := int64(chainreg.DefaultBitcoinTimeLockDelta) + + // Going A -> Carol -> Bob + feeStandardSingleHop := int64(1_000) + probeAmt/1_000_000 + + // Going A -> Carol -> Bob -> Paula/no node + feeBP := int64(bobHopHint.FeeBaseMsat) + + int64(bobHopHint.FeeProportionalMillionths)*(probeAmt)/1_000_000 + deltaBP := int64(bobHopHint.CltvExpiryDelta) + + feeCB := int64(1_000) + (probeAmt+feeBP)/1_000_000 + deltaCB := defaultTimelock + + deltaACBP := deltaCB + deltaBP + feeACBP := feeCB + feeBP + + // The expensive alternative to Bob. + expFeeBP := int64(bobExpHint.FeeBaseMsat) + + int64(bobExpHint.FeeProportionalMillionths)*(probeAmt)/1_000_000 + expFeeCB := int64(1_000) + (probeAmt+expFeeBP)/1_000_000 + expensiveFeeACBP := expFeeCB + expFeeBP + + // Going A -> Carol -> Eve -> Paula + feeEP := int64(eveHopHint.FeeBaseMsat) + + int64(eveHopHint.FeeProportionalMillionths)*(probeAmt)/1_000_000 + deltaEP := int64(eveHopHint.CltvExpiryDelta) + + feeCE := int64(1_000) + (probeAmt+feeEP)/1_000_000 + deltaCE := defaultTimelock + + feeACEP := feeEP + feeCE + deltaACEP := deltaCE + deltaEP + + initialBlockHeight := int64(mts.alice.RPC.GetInfo().BlockHeight) + + // Locktime is always composed of the initial block height and the + // time lock delta for the first hop. Additionally, there's a block + // accounting for block height variance. + locktime := initialBlockHeight + defaultTimelock + + int64(routing.BlockPadding) + + var testCases = []*estimateRouteFeeTestCase{ + // Single hop payment is free. + { + name: "graph based estimate, 0 fee", + probing: false, + destination: mts.dave, + expectedRoutingFeesMsat: 0, + expectedCltvDelta: locktime, + expectedFailureReason: failureReasonNone, + }, + // 1000 msat base fee + 100 msat(1ppm*100_000sat) + { + name: "graph based estimate", + probing: false, + destination: mts.bob, + expectedRoutingFeesMsat: feeStandardSingleHop, + expectedCltvDelta: locktime + deltaCB, + expectedFailureReason: failureReasonNone, + }, + // We expect the same result as the graph based estimate to Bob. + { + name: "probe based estimate, empty " + + "route hint", + probing: true, + destination: mts.bob, + routeHints: []*lnrpc.RouteHint{}, + expectedRoutingFeesMsat: feeStandardSingleHop, + expectedCltvDelta: locktime + deltaCB, + expectedFailureReason: failureReasonNone, + }, + // We expect the previous probing results adjusted by Paula's + // hop data. + { + name: "probe based estimate, single" + + " route hint", + probing: true, + destination: paula, + routeHints: singleRouteHint, + expectedRoutingFeesMsat: feeACBP, + expectedCltvDelta: locktime + deltaACBP, + expectedFailureReason: failureReasonNone, + }, + // With multiple route hints and lsp detected, we expect the + // highest of fee settings to be used for estimation. + { + name: "probe based estimate, " + + "multiple route hints, diff lsp fees", + probing: true, + destination: paula, + routeHints: lspDifferentFeesHints, + expectedRoutingFeesMsat: expensiveFeeACBP, + expectedCltvDelta: locktime + deltaACBP, + expectedFailureReason: failureReasonNone, + }, + // A destination without channels and an existing hop hint hop + // should result in the same estimate as if the hidden node was + // connected through a channel. This ensures that the probe is + // actually just send to the LSP and not the destination. + { + name: "single hop hint, destination " + + "without channels", + probing: true, + destination: ht.NewNode( + "ImWithoutChannels", nil, + ), + routeHints: singleRouteHint, + expectedRoutingFeesMsat: feeACBP, + expectedCltvDelta: locktime + deltaACBP, + expectedFailureReason: failureReasonNone, + }, + // Test lnd native hop processing with non lsp probing. Paula is + // lacking liqudity in the channel to Bob, so Eve is used, but + // it has higher fees. + { + name: "probe based estimate, non " + + "lsp", + probing: true, + destination: paula, + routeHints: nonLspProbingRouteHints, + expectedRoutingFeesMsat: feeACEP, + expectedCltvDelta: locktime + deltaACEP, + expectedFailureReason: failureReasonNone, + }, + // We expect a NO_ROUTE error if route hints to paula aren't + // provided while probing the graph. + { + name: "no route via graph", + probing: false, + destination: paula, + expectedError: ErrNoRouteInGraph, + }, + // We expect a NO_ROUTE error if route hints to paula aren't + // provided while sending a probe payment. + { + name: "no route via probe", + probing: true, + destination: paula, + expectedRoutingFeesMsat: 0, + expectedCltvDelta: 0, + expectedFailureReason: failureReasonNoRoute, + }, + } + + for _, testCase := range testCases { + success := ht.Run( + testCase.name, func(tt *testing.T) { + runFeeEstimationTestCase(ht, testCase) + }, + ) + + if !success { + break + } + } + + mts.ht.CloseChannelAssertPending(mts.bob, channelPointBobPaula, false) + mts.ht.CloseChannelAssertPending(mts.eve, channelPointEvePaula, false) + mts.closeChannels() +} + +// runTestCase runs a single test case asserting that test conditions are met. +func runFeeEstimationTestCase(ht *lntest.HarnessTest, + tc *estimateRouteFeeTestCase) { + + // Legacy graph based fee estimation. + var feeReq *routerrpc.RouteFeeRequest + if tc.probing { + payReqs, _, _ := ht.CreatePayReqs( + tc.destination, probeAmount, 1, tc.routeHints..., + ) + feeReq = &routerrpc.RouteFeeRequest{ + PaymentRequest: payReqs[0], + Timeout: 10, + } + } else { + feeReq = &routerrpc.RouteFeeRequest{ + Dest: tc.destination.PubKey[:], + AmtSat: int64(probeAmount), + } + } + + ctx := ht.Context() + + // Kick off the parametrized fee estimation. + resp, err := probeInitiator.RPC.Router.EstimateRouteFee(ctx, feeReq) + if err != nil { + require.ErrorContains(ht, err, tc.expectedError) + + return + } + + require.Equal(ht, tc.expectedFailureReason, resp.FailureReason) + require.Equal( + ht, tc.expectedRoutingFeesMsat, resp.RoutingFeeMsat, + "routing fees", + ) + require.Equal( + ht, tc.expectedCltvDelta, resp.TimeLockDelay, + "cltv delta", + ) +}