Skip to content

Commit 3f46677

Browse files
committed
Split MPP by maximizing expected delivered amount
As suggested by @renepickhardt in #2785
1 parent d4a498c commit 3f46677

File tree

10 files changed

+126
-27
lines changed

10 files changed

+126
-27
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,7 @@ eclair {
394394
mpp {
395395
min-amount-satoshis = 15000 // minimum amount sent via partial HTLCs
396396
max-parts = 5 // maximum number of HTLCs sent per payment: increasing this value will impact performance
397+
splitting-strategy = "randomize"
397398
}
398399
}
399400

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,12 @@ object NodeParams extends Logging {
428428
},
429429
mpp = MultiPartParams(
430430
Satoshi(config.getLong("mpp.min-amount-satoshis")).toMilliSatoshi,
431-
config.getInt("mpp.max-parts")),
431+
config.getInt("mpp.max-parts"),
432+
config.getString("mpp.splitting-strategy") match {
433+
case "full-capacity" => MultiPartParams.FullCapacity
434+
case "randomize" => MultiPartParams.Randomize
435+
case "max-expected-amount" => MultiPartParams.MaxExpectedAmount
436+
}),
432437
experimentName = name,
433438
experimentPercentage = config.getInt("percentage"))
434439

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
@@ -72,7 +72,17 @@ object EclairInternalsSerializer {
7272

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

7787
val pathFindingConfCodec: Codec[PathFindingConf] = (
7888
("randomize" | bool(8)) ::

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

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import com.softwaremill.quicklens.ModifyPimp
2222
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
2323
import fr.acinq.eclair.Logs.LogCategory
2424
import fr.acinq.eclair._
25-
import fr.acinq.eclair.message.SendingMessage
2625
import fr.acinq.eclair.payment.send._
2726
import fr.acinq.eclair.router.Graph.GraphStructure.DirectedGraph.graphEdgeToHop
2827
import fr.acinq.eclair.router.Graph.GraphStructure.{DirectedGraph, GraphEdge}
@@ -420,7 +419,7 @@ object RouteCalculation {
420419
// We want to ensure that the set of routes we find have enough capacity to allow sending the total amount,
421420
// without excluding routes with small capacity when the total amount is small.
422421
val minPartAmount = routeParams.mpp.minPartAmount.max(amount / numRoutes).min(amount)
423-
routeParams.copy(mpp = MultiPartParams(minPartAmount, numRoutes))
422+
routeParams.copy(mpp = MultiPartParams(minPartAmount, numRoutes, routeParams.mpp.splittingStrategy))
424423
}
425424
findRouteInternal(g, localNodeId, targetNodeId, routeParams1.mpp.minPartAmount, maxFee, routeParams1.mpp.maxParts, extraEdges, ignoredEdges, ignoredVertices, routeParams1, currentBlockHeight) match {
426425
case Right(routes) =>
@@ -446,16 +445,16 @@ object RouteCalculation {
446445
// this route doesn't have enough capacity left: we remove it and continue.
447446
split(amount, paths, usedCapacity, routeParams, selectedRoutes)
448447
} else {
449-
val route = if (routeParams.randomize) {
450-
// randomly choose the amount to be between 20% and 100% of the available capacity.
451-
val randomizedAmount = candidate.amount * ((20d + Random.nextInt(81)) / 100)
452-
if (randomizedAmount < routeParams.mpp.minPartAmount) {
453-
candidate.copy(amount = routeParams.mpp.minPartAmount.min(amount))
454-
} else {
455-
candidate.copy(amount = randomizedAmount.min(amount))
456-
}
457-
} else {
458-
candidate.copy(amount = candidate.amount.min(amount))
448+
val route = routeParams.mpp.splittingStrategy match {
449+
case MultiPartParams.Randomize =>
450+
// randomly choose the amount to be between 20% and 100% of the available capacity.
451+
val randomizedAmount = candidate.amount * ((20d + Random.nextInt(81)) / 100)
452+
candidate.copy(amount = randomizedAmount.max(routeParams.mpp.minPartAmount).min(amount))
453+
case MultiPartParams.MaxExpectedAmount =>
454+
val bestAmount = optimizeExpectedValue(current.path, candidate.amount, usedCapacity)
455+
candidate.copy(amount = bestAmount.max(routeParams.mpp.minPartAmount).min(amount))
456+
case MultiPartParams.FullCapacity =>
457+
candidate.copy(amount = candidate.amount.min(amount))
459458
}
460459
updateUsedCapacity(route, usedCapacity)
461460
// NB: we re-enqueue the current path, it may still have capacity for a second HTLC.
@@ -464,6 +463,26 @@ object RouteCalculation {
464463
}
465464
}
466465

466+
private def optimizeExpectedValue(route: Seq[GraphEdge], capacity: MilliSatoshi, usedCapacity: mutable.Map[ShortChannelId, MilliSatoshi]): MilliSatoshi = {
467+
// We search the maximum value of a polynomial between its two smallest roots (0 and the minimum channel capacity on the path).
468+
// We use binary search to find where the derivative changes sign.
469+
var low = 1L
470+
var high = capacity.toLong
471+
while (high - low > 1L) {
472+
val mid = (high + low) / 2
473+
val d = route.drop(1).foldLeft(1.0 / mid) { case (x, edge) =>
474+
val availableCapacity = edge.capacity - usedCapacity.getOrElse(edge.desc.shortChannelId, 0 msat)
475+
x - 1.0 / (availableCapacity.toLong - mid)
476+
}
477+
if (d > 0.0) {
478+
low = mid
479+
} else {
480+
high = mid
481+
}
482+
}
483+
MilliSatoshi(high)
484+
}
485+
467486
/** Compute the maximum amount that we can send through the given route. */
468487
private def computeRouteMaxAmount(route: Seq[GraphEdge], usedCapacity: mutable.Map[ShortChannelId, MilliSatoshi]): Route = {
469488
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
@@ -551,7 +551,20 @@ object Router {
551551
override def fee(amount: MilliSatoshi): MilliSatoshi = fee
552552
}
553553

554-
case class MultiPartParams(minPartAmount: MilliSatoshi, maxParts: Int)
554+
object MultiPartParams {
555+
sealed trait SplittingStrategy
556+
557+
/** Send the full capacity of the route */
558+
object FullCapacity extends SplittingStrategy
559+
560+
/** Send between 20% and 100% of the capacity of the route */
561+
object Randomize extends SplittingStrategy
562+
563+
/** Maximize the expected delivered amount */
564+
object MaxExpectedAmount extends SplittingStrategy
565+
}
566+
567+
case class MultiPartParams(minPartAmount: MilliSatoshi, maxParts: Int, splittingStrategy: MultiPartParams.SplittingStrategy)
555568

556569
case class RouteParams(randomize: Boolean,
557570
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
@@ -204,6 +204,7 @@ object TestConstants {
204204
mpp = MultiPartParams(
205205
minPartAmount = 15000000 msat,
206206
maxParts = 10,
207+
splittingStrategy = MultiPartParams.FullCapacity
207208
),
208209
experimentName = "alice-test-experiment",
209210
experimentPercentage = 100))),
@@ -369,6 +370,7 @@ object TestConstants {
369370
mpp = MultiPartParams(
370371
minPartAmount = 15000000 msat,
371372
maxParts = 10,
373+
splittingStrategy = MultiPartParams.FullCapacity
372374
),
373375
experimentName = "bob-test-experiment",
374376
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
capacityFactor = 0,
6565
hopCost = RelayFees(0 msat, 0),
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
@@ -723,7 +723,7 @@ object MultiPartPaymentLifecycleSpec {
723723
6,
724724
CltvExpiryDelta(1008)),
725725
Left(WeightRatios(1, 0, 0, 0, RelayFees(0 msat, 0))),
726-
MultiPartParams(1000 msat, 5),
726+
MultiPartParams(1000 msat, 5, MultiPartParams.FullCapacity),
727727
experimentName = "my-test-experiment",
728728
experimentPercentage = 100
729729
).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
@@ -279,7 +279,7 @@ class PaymentLifecycleSpec extends BaseRouterSpec {
279279
randomize = false,
280280
boundaries = SearchBoundaries(100 msat, 0.0, 20, CltvExpiryDelta(2016)),
281281
Left(WeightRatios(1, 0, 0, 0, RelayFees(0 msat, 0))),
282-
MultiPartParams(10_000 msat, 5),
282+
MultiPartParams(10_000 msat, 5, MultiPartParams.FullCapacity),
283283
"my-test-experiment",
284284
experimentPercentage = 100
285285
).getDefaultRouteParams

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

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

974974
{
975975
val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = routeParams, currentBlockHeight = BlockHeight(400000))
@@ -985,7 +985,7 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution {
985985
}
986986
{
987987
// We set min-part-amount to a value that excludes channels 1 and 4.
988-
val failure = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = routeParams.copy(mpp = MultiPartParams(16500 msat, 3)), currentBlockHeight = BlockHeight(400000))
988+
val failure = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = routeParams.copy(mpp = MultiPartParams(16500 msat, 3, routeParams.mpp.splittingStrategy)), currentBlockHeight = BlockHeight(400000))
989989
assert(failure == Failure(RouteNotFound))
990990
}
991991
}
@@ -1112,7 +1112,7 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution {
11121112
))
11131113

11141114
val amount = 30000 msat
1115-
val routeParams = DEFAULT_ROUTE_PARAMS.copy(mpp = MultiPartParams(2500 msat, 5))
1115+
val routeParams = DEFAULT_ROUTE_PARAMS.copy(mpp = MultiPartParams(2500 msat, 5, DEFAULT_ROUTE_PARAMS.mpp.splittingStrategy))
11161116
val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = routeParams, currentBlockHeight = BlockHeight(400000))
11171117
assert(routes.forall(_.hops.length == 1), routes)
11181118
assert(routes.length == 3, routes)
@@ -1244,7 +1244,7 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution {
12441244
// | |
12451245
// +--- B --- D ---+
12461246
// Our balance and the amount we want to send are below the minimum part amount.
1247-
val routeParams = DEFAULT_ROUTE_PARAMS.copy(mpp = MultiPartParams(5000 msat, 5))
1247+
val routeParams = DEFAULT_ROUTE_PARAMS.copy(mpp = MultiPartParams(5000 msat, 5, DEFAULT_ROUTE_PARAMS.mpp.splittingStrategy))
12481248
val g = DirectedGraph(List(
12491249
makeEdge(1L, a, b, 50 msat, 100, minHtlc = 1 msat, balance_opt = Some(1500 msat)),
12501250
makeEdge(2L, b, d, 15 msat, 0, minHtlc = 1 msat, capacity = 25 sat),
@@ -1364,22 +1364,22 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution {
13641364
{
13651365
val amount = 15_000_000 msat
13661366
val maxFee = 50_000 msat // this fee is enough to go through the preferred route
1367-
val routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = false, mpp = MultiPartParams(50_000 msat, 5))
1367+
val routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = false, mpp = MultiPartParams(50_000 msat, 5, DEFAULT_ROUTE_PARAMS.mpp.splittingStrategy))
13681368
val Success(routes) = findMultiPartRoute(g, a, d, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000))
13691369
checkRouteAmounts(routes, amount, maxFee)
13701370
assert(routes2Ids(routes) == Set(Seq(100L, 101L)))
13711371
}
13721372
{
13731373
val amount = 15_000_000 msat
13741374
val maxFee = 10_000 msat // this fee is too low to go through the preferred route
1375-
val routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = false, mpp = MultiPartParams(50_000 msat, 5))
1375+
val routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = false, mpp = MultiPartParams(50_000 msat, 5, DEFAULT_ROUTE_PARAMS.mpp.splittingStrategy))
13761376
val failure = findMultiPartRoute(g, a, d, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000))
13771377
assert(failure == Failure(RouteNotFound))
13781378
}
13791379
{
13801380
val amount = 5_000_000 msat
13811381
val maxFee = 10_000 msat // this fee is enough to go through the preferred route, but the cheaper ones can handle it
1382-
val routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = false, mpp = MultiPartParams(50_000 msat, 5))
1382+
val routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = false, mpp = MultiPartParams(50_000 msat, 5, DEFAULT_ROUTE_PARAMS.mpp.splittingStrategy))
13831383
val Success(routes) = findMultiPartRoute(g, a, d, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000))
13841384
assert(routes.length == 5)
13851385
routes.foreach(route => {
@@ -1469,7 +1469,7 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution {
14691469
makeEdge(6L, b, c, 5 msat, 50, minHtlc = 1000 msat, capacity = 20 sat),
14701470
makeEdge(7L, c, f, 5 msat, 10, minHtlc = 1500 msat, capacity = 50 sat)
14711471
))
1472-
val routeParams = DEFAULT_ROUTE_PARAMS.copy(mpp = MultiPartParams(1500 msat, 10))
1472+
val routeParams = DEFAULT_ROUTE_PARAMS.copy(mpp = MultiPartParams(1500 msat, 10, DEFAULT_ROUTE_PARAMS.mpp.splittingStrategy))
14731473

14741474
{
14751475
val (amount, maxFee) = (15000 msat, 50 msat)
@@ -1599,6 +1599,55 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution {
15991599
}
16001600
}
16011601

1602+
test("calculate multipart route to remote node using max expected amount splitting strategy") {
1603+
// +----- B -----+
1604+
// | |
1605+
// A----- C ---- E
1606+
// | |
1607+
// +----- D -----+
1608+
val (amount, maxFee) = (50000 msat, 1000 msat)
1609+
val g = DirectedGraph(List(
1610+
// The A -> B -> E route is the most economic one, but we already have a pending HTLC in it.
1611+
makeEdge(1L, a, b, 50 msat, 0, minHtlc = 100 msat, balance_opt = Some(100000 msat)),
1612+
makeEdge(2L, b, e, 50 msat, 0, minHtlc = 100 msat, capacity = 50 sat),
1613+
makeEdge(3L, a, c, 50 msat, 0, minHtlc = 100 msat, balance_opt = Some(100000 msat)),
1614+
makeEdge(4L, c, e, 50 msat, 0, minHtlc = 100 msat, capacity = 25 sat),
1615+
makeEdge(5L, a, d, 50 msat, 0, minHtlc = 100 msat, balance_opt = Some(100000 msat)),
1616+
makeEdge(6L, d, e, 50 msat, 0, minHtlc = 100 msat, capacity = 25 sat),
1617+
))
1618+
1619+
val routeParams = DEFAULT_ROUTE_PARAMS.copy(mpp = DEFAULT_ROUTE_PARAMS.mpp.copy(splittingStrategy = MultiPartParams.MaxExpectedAmount))
1620+
val Success(routes) = findMultiPartRoute(g, a, e, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000))
1621+
assert(routes.forall(_.hops.length == 2), routes)
1622+
checkRouteAmounts(routes, amount, maxFee)
1623+
assert(routes.map(route => (route.amount, route.hops.head.shortChannelId.toLong)).toSet == Set((25000 msat, 1L), (12500 msat, 3L), (12500 msat, 5L)))
1624+
}
1625+
1626+
test("calculate multipart route to remote node using max expected amount splitting strategy, respect minPartAmount") {
1627+
// +----- B -----+
1628+
// | |
1629+
// A----- C ---- E
1630+
// | |
1631+
// +----- D -----+
1632+
val (amount, maxFee) = (55000 msat, 1000 msat)
1633+
val g = DirectedGraph(List(
1634+
// The A -> B -> E route is the most economic one, but we already have a pending HTLC in it.
1635+
makeEdge(1L, a, b, 50 msat, 0, minHtlc = 100 msat, balance_opt = Some(100000 msat)),
1636+
makeEdge(2L, b, e, 50 msat, 0, minHtlc = 100 msat, capacity = 50 sat),
1637+
makeEdge(3L, a, c, 50 msat, 0, minHtlc = 100 msat, balance_opt = Some(100000 msat)),
1638+
makeEdge(4L, c, e, 50 msat, 0, minHtlc = 100 msat, capacity = 25 sat),
1639+
makeEdge(5L, a, d, 50 msat, 0, minHtlc = 100 msat, balance_opt = Some(100000 msat)),
1640+
makeEdge(6L, d, e, 50 msat, 0, minHtlc = 100 msat, capacity = 25 sat),
1641+
))
1642+
1643+
val routeParams = DEFAULT_ROUTE_PARAMS.copy(mpp = DEFAULT_ROUTE_PARAMS.mpp.copy(minPartAmount = 15000 msat, splittingStrategy = MultiPartParams.MaxExpectedAmount))
1644+
val Success(routes) = findMultiPartRoute(g, a, e, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000))
1645+
routes.foreach(println)
1646+
assert(routes.forall(_.hops.length == 2), routes)
1647+
checkRouteAmounts(routes, amount, maxFee)
1648+
assert(routes.map(route => (route.amount, route.hops.head.shortChannelId.toLong)).toSet == Set((25000 msat, 1L), (15000 msat, 3L), (15000 msat, 5L)))
1649+
}
1650+
16021651
test("loop trap") {
16031652
// +-----------------+
16041653
// | |
@@ -1927,7 +1976,7 @@ object RouteCalculationSpec {
19271976
randomize = false,
19281977
boundaries = SearchBoundaries(21000 msat, 0.03, 6, CltvExpiryDelta(2016)),
19291978
Left(NO_WEIGHT_RATIOS),
1930-
MultiPartParams(1000 msat, 10),
1979+
MultiPartParams(1000 msat, 10, MultiPartParams.FullCapacity),
19311980
experimentName = "my-test-experiment",
19321981
experimentPercentage = 100).getDefaultRouteParams
19331982

0 commit comments

Comments
 (0)