Skip to content

Commit 4981660

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

File tree

11 files changed

+133
-27
lines changed

11 files changed

+133
-27
lines changed

docs/release-notes/eclair-vnext.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ eclair.channel.channel-update {
5252

5353
This feature leaks a bit of information about the balance when the channel is almost empty, if you do not wish to use it, set `eclair.channel.channel-update.balance-thresholds = []`.
5454

55+
### New MPP splitting strategy
56+
57+
Eclair can send large payments using multiple low-capacity routes by sending as much as it can through each route (if `randomize-route-selection = false`) or some random fraction (if `randomize-route-selection = true`).
58+
These splitting strategies are now specified using `mpp.splitting-strategy = "full-capacity"` or `mpp.splitting-strategy = "randomize"`.
59+
In addition, a new strategy is available: `mpp.splitting-strategy = "max-expected-amount"` will send through each route the amount that maximizes the expected delivered amount (amount sent times success probability).
60+
5561
### API changes
5662

5763
- `bumpforceclose` can be used to make a force-close confirm faster, by spending the anchor output (#2743)

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" // Can be either "full-capacity", "randomize" or "max-expected-amount"
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

0 commit comments

Comments
 (0)