Merge pull request #8886 from bitromortac/buildroute-inbound-fees

routing: inbound fees support for BuildRoute
This commit is contained in:
Oliver Gugger
2024-08-07 08:11:17 -06:00
committed by GitHub
7 changed files with 915 additions and 227 deletions

View File

@@ -24,6 +24,7 @@ import (
"github.com/lightningnetwork/lnd/channeldb"
"github.com/lightningnetwork/lnd/channeldb/models"
"github.com/lightningnetwork/lnd/clock"
"github.com/lightningnetwork/lnd/fn"
"github.com/lightningnetwork/lnd/graph"
"github.com/lightningnetwork/lnd/htlcswitch"
"github.com/lightningnetwork/lnd/input"
@@ -1536,10 +1537,10 @@ func TestBuildRoute(t *testing.T) {
}, 6),
// Create two channels from b to c. For building routes, we
// expect the lowest cost channel to be selected. Note that this
// isn't a situation that we are expecting in reality. Routing
// nodes are recommended to keep their channel policies towards
// the same peer identical.
// expect the highest cost channel to be selected. Note that
// this isn't a situation that we are expecting in reality.
// Routing nodes are recommended to keep their channel policies
// towards the same peer identical.
symmetricTestChannel("b", "c", chanCapSat, &testChannelPolicy{
Expiry: 144,
FeeRate: 50000,
@@ -1555,6 +1556,8 @@ func TestBuildRoute(t *testing.T) {
Features: paymentAddrFeatures,
}, 7),
// Create some channels that have conflicting min/max
// constraints.
symmetricTestChannel("a", "e", chanCapSat, &testChannelPolicy{
Expiry: 144,
FeeRate: 80000,
@@ -1569,9 +1572,50 @@ func TestBuildRoute(t *testing.T) {
MaxHTLC: lnwire.NewMSatFromSatoshis(chanCapSat),
Features: paymentAddrFeatures,
}, 4),
// Create some channels that have a conflicting max HTLC
// constraint for one node pair, similar to the b->c channels.
symmetricTestChannel("b", "z", chanCapSat, &testChannelPolicy{
Expiry: 144,
FeeRate: 50000,
MinHTLC: lnwire.NewMSatFromSatoshis(20),
MaxHTLC: lnwire.NewMSatFromSatoshis(25),
Features: paymentAddrFeatures,
}, 3),
symmetricTestChannel("b", "z", chanCapSat, &testChannelPolicy{
Expiry: 144,
FeeRate: 60000,
MinHTLC: lnwire.NewMSatFromSatoshis(20),
MaxHTLC: lnwire.MilliSatoshi(20100),
Features: paymentAddrFeatures,
}, 8),
// Create a route with inbound fees.
symmetricTestChannel("a", "d", chanCapSat, &testChannelPolicy{
Expiry: 144,
FeeRate: 20000,
MinHTLC: lnwire.NewMSatFromSatoshis(5),
MaxHTLC: lnwire.NewMSatFromSatoshis(
chanCapSat,
),
InboundFeeBaseMsat: -1000,
InboundFeeRate: -1000,
}, 9),
symmetricTestChannel("d", "f", chanCapSat, &testChannelPolicy{
Expiry: 144,
FeeRate: 60000,
MinHTLC: lnwire.NewMSatFromSatoshis(20),
MaxHTLC: lnwire.NewMSatFromSatoshis(120),
Features: paymentAddrFeatures,
// The inbound fee will not be active for the last hop.
InboundFeeBaseMsat: 2000,
InboundFeeRate: 2000,
}, 10),
}
testGraph, err := createTestGraphFromChannels(t, true, testChannels, "a")
testGraph, err := createTestGraphFromChannels(
t, true, testChannels, "a",
)
require.NoError(t, err, "unable to create graph")
const startingBlockHeight = 101
@@ -1583,15 +1627,10 @@ func TestBuildRoute(t *testing.T) {
t.Helper()
if len(rt.Hops) != len(expected) {
t.Fatal("hop count mismatch")
}
require.Len(t, rt.Hops, len(expected))
for i, hop := range rt.Hops {
if hop.ChannelID != expected[i] {
t.Fatalf("expected channel %v at pos %v, but "+
"got channel %v",
expected[i], i, hop.ChannelID)
}
require.Equal(t, expected[i], hop.ChannelID)
}
lastHop := rt.Hops[len(rt.Hops)-1]
@@ -1603,101 +1642,204 @@ func TestBuildRoute(t *testing.T) {
_, err = rand.Read(payAddr[:])
require.NoError(t, err)
noAmt := fn.None[lnwire.MilliSatoshi]()
// Test that we can't build a route when no hops are given.
hops = []route.Vertex{}
_, err = ctx.router.BuildRoute(noAmt, hops, nil, 40, nil)
require.Error(t, err)
// Create hop list for an unknown destination.
hops := []route.Vertex{ctx.aliases["b"], ctx.aliases["y"]}
_, err = ctx.router.BuildRoute(noAmt, hops, nil, 40, &payAddr)
noChanErr := ErrNoChannel{}
require.ErrorAs(t, err, &noChanErr)
require.Equal(t, 1, noChanErr.position)
// Create hop list from the route node pubkeys.
hops := []route.Vertex{
ctx.aliases["b"], ctx.aliases["c"],
}
hops = []route.Vertex{ctx.aliases["b"], ctx.aliases["c"]}
amt := lnwire.NewMSatFromSatoshis(100)
// Build the route for the given amount.
rt, err := ctx.router.BuildRoute(
&amt, hops, nil, 40, &payAddr,
)
if err != nil {
t.Fatal(err)
}
rt, err := ctx.router.BuildRoute(fn.Some(amt), hops, nil, 40, &payAddr)
require.NoError(t, err)
// Check that we get the expected route back. The total amount should be
// the amount to deliver to hop c (100 sats) plus the max fee for the
// connection b->c (6 sats).
checkHops(rt, []uint64{1, 7}, payAddr)
if rt.TotalAmount != 106000 {
t.Fatalf("unexpected total amount %v", rt.TotalAmount)
}
require.Equal(t, lnwire.MilliSatoshi(106000), rt.TotalAmount)
// Build the route for the minimum amount.
rt, err = ctx.router.BuildRoute(
nil, hops, nil, 40, &payAddr,
)
if err != nil {
t.Fatal(err)
}
rt, err = ctx.router.BuildRoute(noAmt, hops, nil, 40, &payAddr)
require.NoError(t, err)
// Check that we get the expected route back. The minimum that we can
// send from b to c is 20 sats. Hop b charges 1200 msat for the
// forwarding. The channel between hop a and b can carry amounts in the
// range [5, 100], so 21200 msats is the minimum amount for this route.
checkHops(rt, []uint64{1, 7}, payAddr)
if rt.TotalAmount != 21200 {
t.Fatalf("unexpected total amount %v", rt.TotalAmount)
}
require.Equal(t, lnwire.MilliSatoshi(21200), rt.TotalAmount)
// The receiver gets sent the minimal HTLC amount.
require.Equal(t, lnwire.MilliSatoshi(20000), rt.Hops[1].AmtToForward)
// Test a route that contains incompatible channel htlc constraints.
// There is no amount that can pass through both channel 5 and 4.
hops = []route.Vertex{
ctx.aliases["e"], ctx.aliases["c"],
}
_, err = ctx.router.BuildRoute(
nil, hops, nil, 40, nil,
)
errNoChannel, ok := err.(ErrNoChannel)
if !ok {
t.Fatalf("expected incompatible policies error, but got %v",
err)
}
if errNoChannel.position != 0 {
t.Fatalf("unexpected no channel error position")
}
if errNoChannel.fromNode != ctx.aliases["a"] {
t.Fatalf("unexpected no channel error node")
}
hops = []route.Vertex{ctx.aliases["e"], ctx.aliases["c"]}
_, err = ctx.router.BuildRoute(noAmt, hops, nil, 40, nil)
require.Error(t, err)
noChanErr = ErrNoChannel{}
require.ErrorAs(t, err, &noChanErr)
require.Equal(t, 0, noChanErr.position)
// Test a route that contains channel constraints that lead to a
// different selection of a unified edge, when the amount is rescaled
// for the final edge. From a backward pass we expect the policy of
// channel 8 to be used, because its policy has the highest fee rate,
// bumping the amount to 20000 msat leading to a sender amount of 21200
// msat including the fees for hop over channel 8. In the forward pass
// however, we subtract that fee again, resulting in the min HTLC
// amount. The forward pass doesn't check for a different policy that
// could me more applicable, which is why we don't get back the highest
// amount that could be delivered to the receiver of 21819 msat, using
// policy of channel 3.
hops = []route.Vertex{ctx.aliases["b"], ctx.aliases["z"]}
rt, err = ctx.router.BuildRoute(noAmt, hops, nil, 40, &payAddr)
require.NoError(t, err)
checkHops(rt, []uint64{1, 8}, payAddr)
require.Equal(t, lnwire.MilliSatoshi(21200), rt.TotalAmount)
require.Equal(t, lnwire.MilliSatoshi(20000), rt.Hops[1].AmtToForward)
// Check that we compute a correct forwarding amount that involves
// inbound fees. We expect a similar amount as for the above case of
// b->c, but reduced by the inbound discount on the channel a->d.
// We get 106000 - 1000 (base in) - 0.001 * 106000 (rate in) = 104894.
hops = []route.Vertex{ctx.aliases["d"], ctx.aliases["f"]}
amt = lnwire.NewMSatFromSatoshis(100)
rt, err = ctx.router.BuildRoute(fn.Some(amt), hops, nil, 40, &payAddr)
require.NoError(t, err)
checkHops(rt, []uint64{9, 10}, payAddr)
require.EqualValues(t, 104894, rt.TotalAmount)
// Also check the min amount with inbound fees. The min amount bumps
// this to 20000 msat for the last hop. The outbound fee is 1200 msat,
// the inbound fee is -1021.2 msat (rounded down). This results in a
// total fee of 179 msat, giving a sender amount of 20179 msat. The
// determined receiver amount however reduces this to 20001 msat again
// due to rounding. This would not be compatible with the sender amount
// of 20179 msat, which results in underpayment of 1 msat in fee. There
// is a third pass through newRoute in which this gets corrected to end
hops = []route.Vertex{ctx.aliases["d"], ctx.aliases["f"]}
rt, err = ctx.router.BuildRoute(noAmt, hops, nil, 40, &payAddr)
require.NoError(t, err)
checkHops(rt, []uint64{9, 10}, payAddr)
require.EqualValues(t, 20180, rt.TotalAmount, "%v", rt.TotalAmount)
}
// TestGetPathEdges tests that the getPathEdges function returns the expected
// edges and amount when given a set of unifiers and does not panic.
func TestGetPathEdges(t *testing.T) {
// TestReceiverAmtForwardPass tests that the forward pass returns the expected
// receiver amount when given a set of edges and does not panic.
func TestReceiverAmtForwardPass(t *testing.T) {
t.Parallel()
const startingBlockHeight = 101
ctx := createTestCtxFromFile(t, startingBlockHeight, basicGraphFilePath)
testCases := []struct {
sourceNode route.Vertex
amt lnwire.MilliSatoshi
unifiers []*edgeUnifier
bandwidthHints *bandwidthManager
hops []route.Vertex
name string
amt lnwire.MilliSatoshi
unifiedEdges []*unifiedEdge
hops []route.Vertex
expectedEdges []*models.CachedEdgePolicy
expectedAmt lnwire.MilliSatoshi
expectedErr string
}{{
sourceNode: ctx.aliases["roasbeef"],
unifiers: []*edgeUnifier{
{
edges: []*unifiedEdge{},
localChan: true,
},
expectedAmt lnwire.MilliSatoshi
expectedErr string
}{
{
name: "empty",
expectedErr: "no edges to forward through",
},
expectedErr: fmt.Sprintf("no matching outgoing channel "+
"available for node 0 (%v)", ctx.aliases["roasbeef"]),
}}
{
name: "single edge, no valid policy",
amt: 1000,
unifiedEdges: []*unifiedEdge{
{
policy: &models.CachedEdgePolicy{
MinHTLC: 1001,
},
},
},
expectedErr: fmt.Sprintf("no matching outgoing " +
"channel available for node index 0"),
},
{
name: "single edge",
amt: 1000,
unifiedEdges: []*unifiedEdge{
{
policy: &models.CachedEdgePolicy{
MinHTLC: 1000,
},
},
},
expectedAmt: 1000,
},
{
name: "outbound fee, no rounding",
amt: 1e9,
unifiedEdges: []*unifiedEdge{
{
// The first hop's outbound fee is
// irrelevant in fee calculation.
policy: &models.CachedEdgePolicy{
FeeBaseMSat: 1234,
FeeProportionalMillionths: 1234,
},
},
{
// No rounding is done here.
policy: &models.CachedEdgePolicy{
FeeBaseMSat: 1000,
FeeProportionalMillionths: 1000,
},
},
},
// From an outgoing amount of 999000000 msat, we get
// in = out + base + out * rate = 1000000000.0
//
// The inverse outgoing amount for this is
// out = (in - base) / (1 + rate) =
// (1e9 - 1000) / (1 + 1e-3) = 999000000.0000001,
// which is rounded down.
expectedAmt: 999000000,
},
{
name: "outbound fee, rounding",
amt: 1e9,
unifiedEdges: []*unifiedEdge{
{
// The first hop's outbound fee is
// irrelevant in fee calculation.
policy: &models.CachedEdgePolicy{
FeeBaseMSat: 1234,
FeeProportionalMillionths: 1234,
},
},
{
// This policy is chosen such that we
// round down.
policy: &models.CachedEdgePolicy{
FeeBaseMSat: 1000,
FeeProportionalMillionths: 999,
},
},
},
// The float amount for this is
// out = (in - base) / (1 + rate) =
// (1e9 - 1000) / (1 + 999e-6) = 999000998.002995,
// which is rounded up.
expectedAmt: 999000999,
},
}
for _, tc := range testCases {
pathEdges, amt, err := getPathEdges(
tc.sourceNode, tc.amt, tc.unifiers, tc.bandwidthHints,
tc.hops,
)
amt, err := receiverAmtForwardPass(tc.amt, tc.unifiedEdges)
if tc.expectedErr != "" {
require.Error(t, err)
@@ -1707,11 +1849,285 @@ func TestGetPathEdges(t *testing.T) {
}
require.NoError(t, err)
require.Equal(t, pathEdges, tc.expectedEdges)
require.Equal(t, amt, tc.expectedAmt)
}
}
// TestSenderAmtBackwardPass tests that the computation of the sender amount is
// done correctly for route building.
func TestSenderAmtBackwardPass(t *testing.T) {
bandwidthHints := bandwidthManager{
getLink: func(chanId lnwire.ShortChannelID) (
htlcswitch.ChannelLink, error) {
return nil, nil
},
localChans: make(map[lnwire.ShortChannelID]struct{}),
}
var (
capacity btcutil.Amount = 1_000_000
testReceiverAmt lnwire.MilliSatoshi = 1_000_000
minHTLC lnwire.MilliSatoshi = 1_000
)
edgeUnifiers := []*edgeUnifier{
{
edges: []*unifiedEdge{
{
// This outbound fee doesn't have an
// effect (sender doesn't pay outbound).
policy: &models.CachedEdgePolicy{
FeeBaseMSat: 112,
},
inboundFees: models.InboundFee{
Base: 111,
},
capacity: capacity,
},
},
},
{
edges: []*unifiedEdge{
{
policy: &models.CachedEdgePolicy{
FeeBaseMSat: 222,
},
inboundFees: models.InboundFee{
Base: 222,
},
capacity: capacity,
},
},
},
{
edges: []*unifiedEdge{
{
policy: &models.CachedEdgePolicy{
FeeBaseMSat: 333,
MinHTLC: minHTLC,
},
// In pathfinding, inbound fees are not
// populated for exit hops because the
// newNodeEdgeUnifier enforces this.
// This is important as otherwise we
// would not fail the min HTLC check in
// getEdge.
capacity: capacity,
},
},
},
}
// A search for an amount that is below the minimum HTLC amount should
// fail.
_, _, err := senderAmtBackwardPass(
edgeUnifiers, fn.Some(minHTLC-1), &bandwidthHints,
)
require.Error(t, err)
// Do a min amount search.
_, senderAmount, err := senderAmtBackwardPass(
edgeUnifiers, fn.None[lnwire.MilliSatoshi](), &bandwidthHints,
)
require.NoError(t, err)
require.Equal(t, minHTLC+333+222+222+111, senderAmount)
// Do a search for a specific amount.
unifiedEdges, senderAmount, err := senderAmtBackwardPass(
edgeUnifiers, fn.Some(testReceiverAmt), &bandwidthHints,
)
require.NoError(t, err)
require.Equal(t, testReceiverAmt+333+222+222+111, senderAmount)
// Check that we arrive at the same receiver amount by doing a forward
// pass.
receiverAmt, err := receiverAmtForwardPass(senderAmount, unifiedEdges)
require.NoError(t, err)
require.Equal(t, testReceiverAmt, receiverAmt)
// Insert a policy that leads to rounding.
edgeUnifiers[1] = &edgeUnifier{
edges: []*unifiedEdge{
{
policy: &models.CachedEdgePolicy{
FeeBaseMSat: 20,
FeeProportionalMillionths: 100,
},
inboundFees: models.InboundFee{
Base: -10,
Rate: -50,
},
capacity: capacity,
},
},
}
unifiedEdges, senderAmount, err = senderAmtBackwardPass(
edgeUnifiers, fn.Some(testReceiverAmt), &bandwidthHints,
)
require.NoError(t, err)
// For this route, we have some rounding errors, so we can't expect the
// exact amount, but it should be higher than the exact amount, to not
// end up below a min HTLC constraint.
receiverAmt, err = receiverAmtForwardPass(senderAmount, unifiedEdges)
require.NoError(t, err)
require.NotEqual(t, testReceiverAmt, receiverAmt)
require.InDelta(t, int64(testReceiverAmt), int64(receiverAmt), 1)
require.GreaterOrEqual(t, int64(receiverAmt), int64(testReceiverAmt))
}
// TestInboundOutbound tests the functions that computes the incoming and
// outgoing amounts based on the fees of the incoming and outgoing channels.
func TestInboundOutbound(t *testing.T) {
var outgoingAmt uint64 = 10_000_000
tests := []struct {
name string
incomingBase int32
incomingRate int32
outgoingBase uint64
outgoingRate uint64
}{
{
name: "only outbound fee",
incomingBase: 0,
incomingRate: 0,
outgoingBase: 20,
outgoingRate: 100,
},
{
name: "positive inbound and outbound fee",
incomingBase: 20,
incomingRate: 100,
outgoingBase: 20,
outgoingRate: 100,
},
{
name: "small negative inbound and outbound fee",
incomingBase: -10,
incomingRate: -50,
outgoingBase: 20,
outgoingRate: 100,
},
{
name: "equal negative inbound and outbound fee",
incomingBase: -20,
incomingRate: -100,
outgoingBase: 20,
outgoingRate: 100,
},
{
name: "large negative inbound and outbound fee",
incomingBase: -30,
incomingRate: -200,
outgoingBase: 20,
outgoingRate: 100,
},
{
name: "order of PPM negative inbound and " +
"outbound fee (m=0)",
incomingBase: -30,
incomingRate: -1_000_000,
outgoingBase: 20,
outgoingRate: 100,
},
{
name: "huge negative inbound and " +
"outbound fee (m<0)",
incomingBase: -30,
incomingRate: -2_000_000,
outgoingBase: 20,
outgoingRate: 100,
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(tt *testing.T) {
testInboundOutboundFee(
tt, outgoingAmt, tc.incomingBase,
tc.incomingRate, tc.outgoingBase,
tc.outgoingRate,
)
})
}
}
// testInboundOutboundFee is a helper function that tests the outgoing and
// incoming amount relationship.
func testInboundOutboundFee(t *testing.T, outgoingAmt uint64, inBase,
inRate int32, outBase, outRate uint64) {
debugStr := fmt.Sprintf(
"outAmt=%d, inBase=%d, inRate=%d, outBase=%d, outRate=%d",
outgoingAmt, inBase, inRate, outBase, outRate,
)
incomingEdge := &unifiedEdge{
policy: &models.CachedEdgePolicy{},
inboundFees: models.InboundFee{
Base: inBase,
Rate: inRate,
},
}
outgoingEdge := &unifiedEdge{
policy: &models.CachedEdgePolicy{
FeeBaseMSat: lnwire.MilliSatoshi(
outBase,
),
FeeProportionalMillionths: lnwire.MilliSatoshi(
outRate,
),
},
}
// We compute the incoming amount based on the outgoing amount, which
// mimicks the path finding process.
incomingAmt := incomingFromOutgoing(
lnwire.MilliSatoshi(outgoingAmt), incomingEdge,
outgoingEdge,
)
// We do the reverse and compute the outgoing amount based on the
// incoming amount.
outgoingAmtNew := outgoingFromIncoming(
incomingAmt, incomingEdge, outgoingEdge,
)
// We require that the incoming amount is always larger than or equal to
// the outgoing amount, because total fees (=incoming-outgoing) should
// not become negative.
require.GreaterOrEqual(
t, int64(incomingAmt), int64(outgoingAmtNew), debugStr,
"expected incomingAmt >= outgoingAmtNew",
)
// We check that up to rounding the amounts are equal.
require.InDelta(
t, int64(outgoingAmt), int64(outgoingAmtNew), 1.0, debugStr,
"expected |outgoingAmt - outgoingAmtNew | <= 1",
)
// If we round, the computed outgoing amount should be larger than the
// exact outgoing amount, to not hit any min HTLC limits.
require.GreaterOrEqual(
t, int64(outgoingAmtNew), int64(outgoingAmt), debugStr,
"expected outgoingAmtNew >= outgoingAmt",
)
}
// FuzzInboundOutbound tests the incoming and outgoing amount calculation
// functions with fuzzing.
func FuzzInboundOutboundFee(f *testing.F) {
f.Add(uint64(0), int32(0), int32(0), uint64(0), uint64(0))
f.Fuzz(testInboundOutboundFee)
}
// TestSendToRouteSkipTempErrSuccess validates a successful payment send.
func TestSendToRouteSkipTempErrSuccess(t *testing.T) {
t.Parallel()