Skip to content

Commit 6bb007e

Browse files
committed
Split MPP by maximizing expected delivered amount
As suggested by @renepickhardt in #2785
1 parent cc75b13 commit 6bb007e

File tree

10 files changed

+127
-26
lines changed

10 files changed

+127
-26
lines changed

eclair-core/src/main/resources/reference.conf

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,7 @@ eclair {
461461
mpp {
462462
min-amount-satoshis = 15000 // minimum amount sent via partial HTLCs
463463
max-parts = 5 // maximum number of HTLCs sent per payment: increasing this value will impact performance
464+
splitting-strategy = "randomize" // Can be either "full-capacity", "randomize" or "max-expected-amount"
464465
}
465466

466467
boundaries {

eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -468,7 +468,12 @@ object NodeParams extends Logging {
468468
usePastRelaysData = config.getBoolean("use-past-relay-data")),
469469
mpp = MultiPartParams(
470470
Satoshi(config.getLong("mpp.min-amount-satoshis")).toMilliSatoshi,
471-
config.getInt("mpp.max-parts")),
471+
config.getInt("mpp.max-parts"),
472+
config.getString("mpp.splitting-strategy") match {
473+
case "full-capacity" => MultiPartParams.FullCapacity
474+
case "randomize" => MultiPartParams.Randomize
475+
case "max-expected-amount" => MultiPartParams.MaxExpectedAmount
476+
}),
472477
experimentName = name,
473478
experimentPercentage = config.getInt("percentage"))
474479

eclair-core/src/main/scala/fr/acinq/eclair/remote/EclairInternalsSerializer.scala

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,17 @@ object EclairInternalsSerializer {
7070

7171
val multiPartParamsCodec: Codec[MultiPartParams] = (
7272
("minPartAmount" | millisatoshi) ::
73-
("maxParts" | int32)).as[MultiPartParams]
73+
("maxParts" | int32) ::
74+
("splittingStrategy" | int8.narrow[MultiPartParams.SplittingStrategy]({
75+
case 0 => Attempt.successful(MultiPartParams.FullCapacity)
76+
case 1 => Attempt.successful(MultiPartParams.Randomize)
77+
case 2 => Attempt.successful(MultiPartParams.MaxExpectedAmount)
78+
case n => Attempt.failure(Err(s"Invalid value $n"))
79+
}, {
80+
case MultiPartParams.FullCapacity => 0
81+
case MultiPartParams.Randomize => 1
82+
case MultiPartParams.MaxExpectedAmount => 2
83+
}))).as[MultiPartParams]
7484

7585
val pathFindingConfCodec: Codec[PathFindingConf] = (
7686
("randomize" | bool(8)) ::

eclair-core/src/main/scala/fr/acinq/eclair/router/RouteCalculation.scala

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,7 @@ object RouteCalculation {
429429
// We want to ensure that the set of routes we find have enough capacity to allow sending the total amount,
430430
// without excluding routes with small capacity when the total amount is small.
431431
val minPartAmount = routeParams.mpp.minPartAmount.max(amount / numRoutes).min(amount)
432-
routeParams.copy(mpp = MultiPartParams(minPartAmount, numRoutes))
432+
routeParams.copy(mpp = MultiPartParams(minPartAmount, numRoutes, routeParams.mpp.splittingStrategy))
433433
}
434434
findRouteInternal(g, localNodeId, targetNodeId, routeParams1.mpp.minPartAmount, maxFee, routeParams1.mpp.maxParts, extraEdges, ignoredEdges, ignoredVertices, routeParams1, currentBlockHeight) match {
435435
case Right(routes) =>
@@ -455,16 +455,16 @@ object RouteCalculation {
455455
// this route doesn't have enough capacity left: we remove it and continue.
456456
split(amount, paths, usedCapacity, routeParams, selectedRoutes)
457457
} else {
458-
val route = if (routeParams.randomize) {
459-
// randomly choose the amount to be between 20% and 100% of the available capacity.
460-
val randomizedAmount = candidate.amount * ((20d + Random.nextInt(81)) / 100)
461-
if (randomizedAmount < routeParams.mpp.minPartAmount) {
462-
candidate.copy(amount = routeParams.mpp.minPartAmount.min(amount))
463-
} else {
464-
candidate.copy(amount = randomizedAmount.min(amount))
465-
}
466-
} else {
467-
candidate.copy(amount = candidate.amount.min(amount))
458+
val route = routeParams.mpp.splittingStrategy match {
459+
case MultiPartParams.Randomize =>
460+
// randomly choose the amount to be between 20% and 100% of the available capacity.
461+
val randomizedAmount = candidate.amount * ((20d + Random.nextInt(81)) / 100)
462+
candidate.copy(amount = randomizedAmount.max(routeParams.mpp.minPartAmount).min(amount))
463+
case MultiPartParams.MaxExpectedAmount =>
464+
val bestAmount = optimizeExpectedValue(current.path, candidate.amount, usedCapacity)
465+
candidate.copy(amount = bestAmount.max(routeParams.mpp.minPartAmount).min(amount))
466+
case MultiPartParams.FullCapacity =>
467+
candidate.copy(amount = candidate.amount.min(amount))
468468
}
469469
updateUsedCapacity(route, usedCapacity)
470470
// NB: we re-enqueue the current path, it may still have capacity for a second HTLC.
@@ -473,6 +473,26 @@ object RouteCalculation {
473473
}
474474
}
475475

476+
private def optimizeExpectedValue(route: Seq[GraphEdge], capacity: MilliSatoshi, usedCapacity: mutable.Map[ShortChannelId, MilliSatoshi]): MilliSatoshi = {
477+
// We search the maximum value of a polynomial between its two smallest roots (0 and the minimum channel capacity on the path).
478+
// We use binary search to find where the derivative changes sign.
479+
var low = 1L
480+
var high = capacity.toLong
481+
while (high - low > 1L) {
482+
val mid = (high + low) / 2
483+
val d = route.drop(1).foldLeft(1.0 / mid) { case (x, edge) =>
484+
val availableCapacity = edge.capacity - usedCapacity.getOrElse(edge.desc.shortChannelId, 0 msat)
485+
x - 1.0 / (availableCapacity.toLong - mid)
486+
}
487+
if (d > 0.0) {
488+
low = mid
489+
} else {
490+
high = mid
491+
}
492+
}
493+
MilliSatoshi(high)
494+
}
495+
476496
/** Compute the maximum amount that we can send through the given route. */
477497
private def computeRouteMaxAmount(route: Seq[GraphEdge], usedCapacity: mutable.Map[ShortChannelId, MilliSatoshi]): Route = {
478498
val firstHopMaxAmount = route.head.maxHtlcAmount(usedCapacity.getOrElse(route.head.desc.shortChannelId, 0 msat))

eclair-core/src/main/scala/fr/acinq/eclair/router/Router.scala

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -577,7 +577,20 @@ object Router {
577577
override def fee(amount: MilliSatoshi): MilliSatoshi = fee
578578
}
579579

580-
case class MultiPartParams(minPartAmount: MilliSatoshi, maxParts: Int)
580+
object MultiPartParams {
581+
sealed trait SplittingStrategy
582+
583+
/** Send the full capacity of the route */
584+
object FullCapacity extends SplittingStrategy
585+
586+
/** Send between 20% and 100% of the capacity of the route */
587+
object Randomize extends SplittingStrategy
588+
589+
/** Maximize the expected delivered amount */
590+
object MaxExpectedAmount extends SplittingStrategy
591+
}
592+
593+
case class MultiPartParams(minPartAmount: MilliSatoshi, maxParts: Int, splittingStrategy: MultiPartParams.SplittingStrategy)
581594

582595
case class RouteParams(randomize: Boolean,
583596
boundaries: SearchBoundaries,

eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ object TestConstants {
231231
mpp = MultiPartParams(
232232
minPartAmount = 15000000 msat,
233233
maxParts = 10,
234+
splittingStrategy = MultiPartParams.FullCapacity
234235
),
235236
experimentName = "alice-test-experiment",
236237
experimentPercentage = 100))),
@@ -422,6 +423,7 @@ object TestConstants {
422423
mpp = MultiPartParams(
423424
minPartAmount = 15000000 msat,
424425
maxParts = 10,
426+
splittingStrategy = MultiPartParams.FullCapacity
425427
),
426428
experimentName = "bob-test-experiment",
427429
experimentPercentage = 100))),

eclair-core/src/test/scala/fr/acinq/eclair/integration/IntegrationSpec.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ abstract class IntegrationSpec extends TestKitBaseClass with BitcoindService wit
6464
useLogProbability = false,
6565
usePastRelaysData = false
6666
),
67-
mpp = MultiPartParams(15000000 msat, 6),
67+
mpp = MultiPartParams(15000000 msat, 6, MultiPartParams.FullCapacity),
6868
experimentName = "my-test-experiment",
6969
experimentPercentage = 100
7070
).getDefaultRouteParams

eclair-core/src/test/scala/fr/acinq/eclair/payment/MultiPartPaymentLifecycleSpec.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -697,7 +697,7 @@ object MultiPartPaymentLifecycleSpec {
697697
6,
698698
CltvExpiryDelta(1008)),
699699
HeuristicsConstants(0, RelayFees(0 msat, 0), RelayFees(0 msat, 0), useLogProbability = false, usePastRelaysData = false),
700-
MultiPartParams(1000 msat, 5),
700+
MultiPartParams(1000 msat, 5, MultiPartParams.FullCapacity),
701701
experimentName = "my-test-experiment",
702702
experimentPercentage = 100
703703
).getDefaultRouteParams

eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentLifecycleSpec.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,7 @@ class PaymentLifecycleSpec extends BaseRouterSpec {
302302
randomize = false,
303303
boundaries = SearchBoundaries(100 msat, 0.0, 20, CltvExpiryDelta(2016)),
304304
HeuristicsConstants(0, RelayFees(0 msat, 0), RelayFees(0 msat, 0), useLogProbability = false, usePastRelaysData = false),
305-
MultiPartParams(10_000 msat, 5),
305+
MultiPartParams(10_000 msat, 5, MultiPartParams.FullCapacity),
306306
"my-test-experiment",
307307
experimentPercentage = 100
308308
).getDefaultRouteParams

eclair-core/src/test/scala/fr/acinq/eclair/router/RouteCalculationSpec.scala

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -905,7 +905,7 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution {
905905
makeEdge(4L, a, b, 100 msat, 20, minHtlc = 1 msat, balance_opt = Some(16000 msat)),
906906
)), 1 day)
907907
// We set max-parts to 3, but it should be ignored when sending to a direct neighbor.
908-
val routeParams = DEFAULT_ROUTE_PARAMS.copy(mpp = MultiPartParams(2500 msat, 3))
908+
val routeParams = DEFAULT_ROUTE_PARAMS.copy(mpp = MultiPartParams(2500 msat, 3, DEFAULT_ROUTE_PARAMS.mpp.splittingStrategy))
909909

910910
{
911911
val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = routeParams, currentBlockHeight = BlockHeight(400000))
@@ -921,7 +921,7 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution {
921921
}
922922
{
923923
// We set min-part-amount to a value that excludes channels 1 and 4.
924-
val failure = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = routeParams.copy(mpp = MultiPartParams(16500 msat, 3)), currentBlockHeight = BlockHeight(400000))
924+
val failure = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = routeParams.copy(mpp = MultiPartParams(16500 msat, 3, routeParams.mpp.splittingStrategy)), currentBlockHeight = BlockHeight(400000))
925925
assert(failure == Failure(RouteNotFound))
926926
}
927927
}
@@ -1048,7 +1048,7 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution {
10481048
)), 1 day)
10491049

10501050
val amount = 30000 msat
1051-
val routeParams = DEFAULT_ROUTE_PARAMS.copy(mpp = MultiPartParams(2500 msat, 5))
1051+
val routeParams = DEFAULT_ROUTE_PARAMS.copy(mpp = MultiPartParams(2500 msat, 5, DEFAULT_ROUTE_PARAMS.mpp.splittingStrategy))
10521052
val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = routeParams, currentBlockHeight = BlockHeight(400000))
10531053
assert(routes.forall(_.hops.length == 1), routes)
10541054
assert(routes.length == 3, routes)
@@ -1180,7 +1180,7 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution {
11801180
// | |
11811181
// +--- B --- D ---+
11821182
// Our balance and the amount we want to send are below the minimum part amount.
1183-
val routeParams = DEFAULT_ROUTE_PARAMS.copy(mpp = MultiPartParams(5000 msat, 5))
1183+
val routeParams = DEFAULT_ROUTE_PARAMS.copy(mpp = MultiPartParams(5000 msat, 5, DEFAULT_ROUTE_PARAMS.mpp.splittingStrategy))
11841184
val g = GraphWithBalanceEstimates(DirectedGraph(List(
11851185
makeEdge(1L, a, b, 50 msat, 100, minHtlc = 1 msat, balance_opt = Some(1500 msat)),
11861186
makeEdge(2L, b, d, 15 msat, 0, minHtlc = 1 msat, capacity = 25 sat),
@@ -1300,22 +1300,22 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution {
13001300
{
13011301
val amount = 15_000_000 msat
13021302
val maxFee = 50_000 msat // this fee is enough to go through the preferred route
1303-
val routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = false, mpp = MultiPartParams(50_000 msat, 5))
1303+
val routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = false, mpp = MultiPartParams(50_000 msat, 5, DEFAULT_ROUTE_PARAMS.mpp.splittingStrategy))
13041304
val Success(routes) = findMultiPartRoute(g, a, d, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000))
13051305
checkRouteAmounts(routes, amount, maxFee)
13061306
assert(routes2Ids(routes) == Set(Seq(100L, 101L)))
13071307
}
13081308
{
13091309
val amount = 15_000_000 msat
13101310
val maxFee = 10_000 msat // this fee is too low to go through the preferred route
1311-
val routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = false, mpp = MultiPartParams(50_000 msat, 5))
1311+
val routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = false, mpp = MultiPartParams(50_000 msat, 5, DEFAULT_ROUTE_PARAMS.mpp.splittingStrategy))
13121312
val failure = findMultiPartRoute(g, a, d, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000))
13131313
assert(failure == Failure(RouteNotFound))
13141314
}
13151315
{
13161316
val amount = 5_000_000 msat
13171317
val maxFee = 10_000 msat // this fee is enough to go through the preferred route, but the cheaper ones can handle it
1318-
val routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = false, mpp = MultiPartParams(50_000 msat, 5))
1318+
val routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = false, mpp = MultiPartParams(50_000 msat, 5, DEFAULT_ROUTE_PARAMS.mpp.splittingStrategy))
13191319
val Success(routes) = findMultiPartRoute(g, a, d, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000))
13201320
assert(routes.length == 5)
13211321
routes.foreach(route => {
@@ -1405,7 +1405,7 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution {
14051405
makeEdge(6L, b, c, 5 msat, 50, minHtlc = 1000 msat, capacity = 20 sat),
14061406
makeEdge(7L, c, f, 5 msat, 10, minHtlc = 1500 msat, capacity = 50 sat)
14071407
)), 1 day)
1408-
val routeParams = DEFAULT_ROUTE_PARAMS.copy(mpp = MultiPartParams(1500 msat, 10))
1408+
val routeParams = DEFAULT_ROUTE_PARAMS.copy(mpp = MultiPartParams(1500 msat, 10, DEFAULT_ROUTE_PARAMS.mpp.splittingStrategy))
14091409

14101410
{
14111411
val (amount, maxFee) = (15000 msat, 50 msat)
@@ -1535,6 +1535,56 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution {
15351535
}
15361536
}
15371537

1538+
test("calculate multipart route to remote node using max expected amount splitting strategy") {
1539+
// A-------------E
1540+
// | |
1541+
// +----- B -----+
1542+
// | |
1543+
// +----- C ---- +
1544+
// | |
1545+
// +----- D -----+
1546+
val (amount, maxFee) = (60000 msat, 1000 msat)
1547+
val g = GraphWithBalanceEstimates(DirectedGraph(List(
1548+
// The A -> B -> E route is the most economic one, but we already have a pending HTLC in it.
1549+
makeEdge(0L, a, e, 50 msat, 0, minHtlc = 100 msat, balance_opt = Some(10000 msat)),
1550+
makeEdge(1L, a, b, 50 msat, 0, minHtlc = 100 msat, balance_opt = Some(100000 msat)),
1551+
makeEdge(2L, b, e, 50 msat, 0, minHtlc = 100 msat, capacity = 50 sat),
1552+
makeEdge(3L, a, c, 50 msat, 0, minHtlc = 100 msat, balance_opt = Some(100000 msat)),
1553+
makeEdge(4L, c, e, 50 msat, 0, minHtlc = 100 msat, capacity = 25 sat),
1554+
makeEdge(5L, a, d, 50 msat, 0, minHtlc = 100 msat, balance_opt = Some(100000 msat)),
1555+
makeEdge(6L, d, e, 50 msat, 0, minHtlc = 100 msat, capacity = 25 sat),
1556+
)), 1 day)
1557+
1558+
val routeParams = DEFAULT_ROUTE_PARAMS.copy(mpp = DEFAULT_ROUTE_PARAMS.mpp.copy(splittingStrategy = MultiPartParams.MaxExpectedAmount))
1559+
val Success(routes) = findMultiPartRoute(g, a, e, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000))
1560+
checkRouteAmounts(routes, amount, maxFee)
1561+
assert(routes.map(route => (route.amount, route.hops.head.shortChannelId.toLong)).toSet == Set((10000 msat, 0L), (25000 msat, 1L), (12500 msat, 3L), (12500 msat, 5L)))
1562+
}
1563+
1564+
test("calculate multipart route to remote node using max expected amount splitting strategy, respect minPartAmount") {
1565+
// +----- B -----+
1566+
// | |
1567+
// A----- C ---- E
1568+
// | |
1569+
// +----- D -----+
1570+
val (amount, maxFee) = (55000 msat, 1000 msat)
1571+
val g = GraphWithBalanceEstimates(DirectedGraph(List(
1572+
// The A -> B -> E route is the most economic one, but we already have a pending HTLC in it.
1573+
makeEdge(1L, a, b, 50 msat, 0, minHtlc = 100 msat, balance_opt = Some(100000 msat)),
1574+
makeEdge(2L, b, e, 50 msat, 0, minHtlc = 100 msat, capacity = 50 sat),
1575+
makeEdge(3L, a, c, 50 msat, 0, minHtlc = 100 msat, balance_opt = Some(100000 msat)),
1576+
makeEdge(4L, c, e, 50 msat, 0, minHtlc = 100 msat, capacity = 25 sat),
1577+
makeEdge(5L, a, d, 50 msat, 0, minHtlc = 100 msat, balance_opt = Some(100000 msat)),
1578+
makeEdge(6L, d, e, 50 msat, 0, minHtlc = 100 msat, capacity = 25 sat),
1579+
)), 1 day)
1580+
1581+
val routeParams = DEFAULT_ROUTE_PARAMS.copy(mpp = DEFAULT_ROUTE_PARAMS.mpp.copy(minPartAmount = 15000 msat, splittingStrategy = MultiPartParams.MaxExpectedAmount))
1582+
val Success(routes) = findMultiPartRoute(g, a, e, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000))
1583+
assert(routes.forall(_.hops.length == 2), routes)
1584+
checkRouteAmounts(routes, amount, maxFee)
1585+
assert(routes.map(route => (route.amount, route.hops.head.shortChannelId.toLong)).toSet == Set((25000 msat, 1L), (15000 msat, 3L), (15000 msat, 5L)))
1586+
}
1587+
15381588
test("loop trap") {
15391589
// +-----------------+
15401590
// | |
@@ -1899,7 +1949,7 @@ object RouteCalculationSpec {
18991949
randomize = false,
19001950
boundaries = SearchBoundaries(21000 msat, 0.03, 6, CltvExpiryDelta(2016)),
19011951
NO_WEIGHT_RATIOS,
1902-
MultiPartParams(1000 msat, 10),
1952+
MultiPartParams(1000 msat, 10, MultiPartParams.FullCapacity),
19031953
experimentName = "my-test-experiment",
19041954
experimentPercentage = 100).getDefaultRouteParams
19051955

0 commit comments

Comments
 (0)