Skip to content

Commit 51a144c

Browse files
Split MPP by maximizing expected delivered amount (#2792)
As suggested by @renepickhardt in #2785
1 parent 3208266 commit 51a144c

File tree

16 files changed

+276
-128
lines changed

16 files changed

+276
-128
lines changed

docs/release-notes/eclair-vnext.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@
44

55
## Major changes
66

7-
<insert changes>
7+
### New MPP splitting strategy
8+
9+
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`).
10+
These splitting strategies are now specified using `mpp.splitting-strategy = "full-capacity"` or `mpp.splitting-strategy = "randomize"`.
11+
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 multiplied by the success probability).
12+
13+
Eclair's path-finding algorithm can be customized by modifying the `eclair.router.path-finding.experiments.*` sections of your `eclair.conf`.
14+
The new `mpp.splitting-strategy` goes in these sections, or in `eclair.router.path-finding.default` from which they inherit.
815

916
### Remove support for legacy channel codecs
1017

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/payment/send/MultiPartPaymentLifecycle.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import fr.acinq.eclair.payment.PaymentSent.PartialPayment
2727
import fr.acinq.eclair.payment._
2828
import fr.acinq.eclair.payment.send.PaymentInitiator.SendPaymentConfig
2929
import fr.acinq.eclair.payment.send.PaymentLifecycle.SendPaymentToRoute
30+
import fr.acinq.eclair.router.Router.MultiPartParams.FullCapacity
3031
import fr.acinq.eclair.router.Router._
3132
import fr.acinq.eclair.{FSMDiagnosticActorLogging, Logs, MilliSatoshiLong, NodeParams, TimestampMilli}
3233
import scodec.bits.ByteVector
@@ -57,7 +58,7 @@ class MultiPartPaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig,
5758

5859
when(WAIT_FOR_PAYMENT_REQUEST) {
5960
case Event(r: SendMultiPartPayment, _) =>
60-
val routeParams = r.routeParams.copy(randomize = false) // we don't randomize the first attempt, regardless of configuration choices
61+
val routeParams = r.routeParams.copy(randomize = false, mpp = r.routeParams.mpp.copy(splittingStrategy = FullCapacity)) // we don't randomize the first attempt, regardless of configuration choices
6162
log.debug("sending {} with maximum fee {}", r.recipient.totalAmount, r.routeParams.getMaxFee(r.recipient.totalAmount))
6263
val d = PaymentProgress(r, r.maxAttempts, Map.empty, Ignore.empty, retryRouteRequest = false, failures = Nil)
6364
router ! createRouteRequest(self, nodeParams, routeParams, d, cfg)

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,17 @@ object EclairInternalsSerializer {
6464
("useLogProbability" | bool(8)) ::
6565
("usePastRelaysData" | bool(8))).as[HeuristicsConstants]
6666

67-
val weightRatiosCodec: Codec[WeightRatios[PaymentPathWeight]] =
68-
discriminated[WeightRatios[PaymentPathWeight]].by(uint8)
67+
val weightRatiosCodec: Codec[HeuristicsConstants] =
68+
discriminated[HeuristicsConstants].by(uint8)
6969
.typecase(0xff, heuristicsConstantsCodec)
7070

7171
val multiPartParamsCodec: Codec[MultiPartParams] = (
7272
("minPartAmount" | millisatoshi) ::
73-
("maxParts" | int32)).as[MultiPartParams]
73+
("maxParts" | int32) ::
74+
("splittingStrategy" | discriminated[MultiPartParams.SplittingStrategy].by(uint8)
75+
.typecase(0, provide(MultiPartParams.FullCapacity))
76+
.typecase(1, provide(MultiPartParams.Randomize))
77+
.typecase(2, provide(MultiPartParams.MaxExpectedAmount)))).as[MultiPartParams]
7478

7579
val pathFindingConfCodec: Codec[PathFindingConf] = (
7680
("randomize" | bool(8)) ::

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ case class BalanceEstimate private(low: MilliSatoshi,
216216
* - probability that it can relay a payment of high is decay(high, 0, highTimestamp) which is close to 0 if highTimestamp is recent
217217
* - probability that it can relay a payment of maxCapacity is 0
218218
*/
219-
def canSend(amount: MilliSatoshi, now: TimestampSecond): Double = {
219+
def canSendAndDerivative(amount: MilliSatoshi, now: TimestampSecond): (Double, Double) = {
220220
val a = amount.toLong.toDouble
221221
val l = low.toLong.toDouble
222222
val h = high.toLong.toDouble
@@ -227,15 +227,17 @@ case class BalanceEstimate private(low: MilliSatoshi,
227227
val pHigh = decay(high, 0, highTimestamp, now)
228228

229229
if (amount < low) {
230-
(l - a * (1.0 - pLow)) / l
230+
((l - a * (1.0 - pLow)) / l, (pLow - 1.0) / l)
231231
} else if (amount < high) {
232-
((h - a) * pLow + (a - l) * pHigh) / (h - l)
232+
(((h - a) * pLow + (a - l) * pHigh) / (h - l), (pHigh - pLow) / (h - l))
233233
} else if (h < c) {
234-
((c - a) * pHigh) / (c - h)
234+
(((c - a) * pHigh) / (c - h), (-pHigh) / (c - h))
235235
} else {
236-
0
236+
(0.0, 0.0)
237237
}
238238
}
239+
240+
def canSend(amount: MilliSatoshi, now: TimestampSecond): Double = canSendAndDerivative(amount, now)._1
239241
}
240242

241243
object BalanceEstimate {

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

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -501,13 +501,6 @@ object Graph {
501501
* @param balance_opt (optional) available balance that can be sent through this edge
502502
*/
503503
case class GraphEdge private(desc: ChannelDesc, params: HopRelayParams, capacity: Satoshi, balance_opt: Option[MilliSatoshi]) {
504-
505-
def maxHtlcAmount(reservedCapacity: MilliSatoshi): MilliSatoshi = Seq(
506-
balance_opt.map(balance => balance - reservedCapacity),
507-
params.htlcMaximum_opt,
508-
Some(capacity.toMilliSatoshi - reservedCapacity)
509-
).flatten.min.max(0 msat)
510-
511504
def fee(amount: MilliSatoshi): MilliSatoshi = params.fee(amount)
512505
}
513506

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

Lines changed: 100 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -390,14 +390,7 @@ object RouteCalculation {
390390
pendingHtlcs: Seq[Route] = Nil,
391391
routeParams: RouteParams,
392392
currentBlockHeight: BlockHeight): Try[Seq[Route]] = Try {
393-
val result = findMultiPartRouteInternal(g, localNodeId, targetNodeId, amount, maxFee, extraEdges, ignoredEdges, ignoredVertices, pendingHtlcs, routeParams, currentBlockHeight) match {
394-
case Right(routes) => Right(routes)
395-
case Left(RouteNotFound) if routeParams.randomize =>
396-
// If we couldn't find a randomized solution, fallback to a deterministic one.
397-
findMultiPartRouteInternal(g, localNodeId, targetNodeId, amount, maxFee, extraEdges, ignoredEdges, ignoredVertices, pendingHtlcs, routeParams.copy(randomize = false), currentBlockHeight)
398-
case Left(ex) => Left(ex)
399-
}
400-
result match {
393+
findMultiPartRouteInternal(g, localNodeId, targetNodeId, amount, maxFee, extraEdges, ignoredEdges, ignoredVertices, pendingHtlcs, routeParams, currentBlockHeight) match {
401394
case Right(routes) => routes
402395
case Left(ex) => return Failure(ex)
403396
}
@@ -413,7 +406,8 @@ object RouteCalculation {
413406
ignoredVertices: Set[PublicKey] = Set.empty,
414407
pendingHtlcs: Seq[Route] = Nil,
415408
routeParams: RouteParams,
416-
currentBlockHeight: BlockHeight): Either[RouterException, Seq[Route]] = {
409+
currentBlockHeight: BlockHeight,
410+
now: TimestampSecond = TimestampSecond.now()): Either[RouterException, Seq[Route]] = {
417411
// We use Yen's k-shortest paths to find many paths for chunks of the total amount.
418412
// When the recipient is a direct peer, we have complete visibility on our local channels so we can use more accurate MPP parameters.
419413
val routeParams1 = {
@@ -429,63 +423,124 @@ object RouteCalculation {
429423
// We want to ensure that the set of routes we find have enough capacity to allow sending the total amount,
430424
// without excluding routes with small capacity when the total amount is small.
431425
val minPartAmount = routeParams.mpp.minPartAmount.max(amount / numRoutes).min(amount)
432-
routeParams.copy(mpp = MultiPartParams(minPartAmount, numRoutes))
426+
routeParams.copy(mpp = MultiPartParams(minPartAmount, numRoutes, routeParams.mpp.splittingStrategy))
433427
}
434428
findRouteInternal(g, localNodeId, targetNodeId, routeParams1.mpp.minPartAmount, maxFee, routeParams1.mpp.maxParts, extraEdges, ignoredEdges, ignoredVertices, routeParams1, currentBlockHeight) match {
435-
case Right(routes) =>
429+
case Right(paths) =>
436430
// We use these shortest paths to find a set of non-conflicting HTLCs that send the total amount.
437-
split(amount, mutable.Queue(routes: _*), initializeUsedCapacity(pendingHtlcs), routeParams1) match {
431+
split(amount, mutable.Queue(paths: _*), initializeUsedCapacity(pendingHtlcs), routeParams1, g.balances, now) match {
438432
case Right(routes) if validateMultiPartRoute(amount, maxFee, routes, routeParams.includeLocalChannelCost) => Right(routes)
433+
case Right(_) if routeParams.randomize =>
434+
// We've found a multipart route, but it's too expensive. We try again without randomization to prioritize cheaper paths.
435+
val sortedPaths = paths.sortBy(_.weight.weight)
436+
split(amount, mutable.Queue(sortedPaths: _*), initializeUsedCapacity(pendingHtlcs), routeParams1, g.balances, now) match {
437+
case Right(routes) if validateMultiPartRoute(amount, maxFee, routes, routeParams.includeLocalChannelCost) => Right(routes)
438+
case _ => Left(RouteNotFound)
439+
}
439440
case _ => Left(RouteNotFound)
440441
}
441442
case Left(ex) => Left(ex)
442443
}
443444
}
444445

445-
@tailrec
446-
private def split(amount: MilliSatoshi, paths: mutable.Queue[WeightedPath[PaymentPathWeight]], usedCapacity: mutable.Map[ShortChannelId, MilliSatoshi], routeParams: RouteParams, selectedRoutes: Seq[Route] = Nil): Either[RouterException, Seq[Route]] = {
447-
if (amount == 0.msat) {
448-
Right(selectedRoutes)
449-
} else if (paths.isEmpty) {
450-
Left(RouteNotFound)
451-
} else {
446+
private case class CandidateRoute(route: Route, maxAmount: MilliSatoshi)
447+
448+
private def split(amount: MilliSatoshi, paths: mutable.Queue[WeightedPath[PaymentPathWeight]], usedCapacity: mutable.Map[ShortChannelId, MilliSatoshi], routeParams: RouteParams, balances: BalancesEstimates, now: TimestampSecond): Either[RouterException, Seq[Route]] = {
449+
var amountLeft = amount
450+
var candidates: List[CandidateRoute] = Nil
451+
// We build some candidate route but may adjust the amounts later if the don't cover the full amount we need to send.
452+
while(paths.nonEmpty && amountLeft > 0.msat) {
452453
val current = paths.dequeue()
453454
val candidate = computeRouteMaxAmount(current.path, usedCapacity)
454-
if (candidate.amount < routeParams.mpp.minPartAmount.min(amount)) {
455-
// this route doesn't have enough capacity left: we remove it and continue.
456-
split(amount, paths, usedCapacity, routeParams, selectedRoutes)
457-
} 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-
}
455+
if (candidate.amount >= routeParams.mpp.minPartAmount.min(amountLeft)) {
456+
val chosenAmount = routeParams.mpp.splittingStrategy match {
457+
case MultiPartParams.Randomize =>
458+
// randomly choose the amount to be between 20% and 100% of the available capacity.
459+
val randomizedAmount = candidate.amount * ((20d + Random.nextInt(81)) / 100)
460+
randomizedAmount.max(routeParams.mpp.minPartAmount).min(amountLeft)
461+
case MultiPartParams.MaxExpectedAmount =>
462+
val bestAmount = optimizeExpectedValue(current.path, candidate.amount, usedCapacity, routeParams.heuristics.usePastRelaysData, balances, now)
463+
bestAmount.max(routeParams.mpp.minPartAmount).min(amountLeft)
464+
case MultiPartParams.FullCapacity =>
465+
candidate.amount.min(amountLeft)
466+
}
467+
// We update the route with our chosen amount, which is always smaller than the maximum amount.
468+
val chosenRoute = CandidateRoute(candidate.copy(amount = chosenAmount), candidate.amount)
469+
// But we use the route with its maximum amount when updating the used capacity, because we may use more funds below when adjusting the amounts.
470+
updateUsedCapacity(candidate, usedCapacity)
471+
candidates = chosenRoute :: candidates
472+
amountLeft = amountLeft - chosenAmount
473+
paths.enqueue(current)
474+
}
475+
}
476+
// We adjust the amounts to send through each route.
477+
val totalMaximum = candidates.map(_.maxAmount).sum
478+
if (amountLeft == 0.msat) {
479+
Right(candidates.map(_.route))
480+
} else if (totalMaximum < amount) {
481+
Left(RouteNotFound)
482+
} else {
483+
val totalChosen = candidates.map(_.route.amount).sum
484+
val additionalFraction = (amount - totalChosen).toLong.toDouble / (totalMaximum - totalChosen).toLong.toDouble
485+
var routes: List[Route] = Nil
486+
var amountLeft = amount
487+
candidates.foreach { case CandidateRoute(route, maxAmount) =>
488+
if (amountLeft > 0.msat) {
489+
val additionalAmount = MilliSatoshi(((maxAmount - route.amount).toLong * additionalFraction).ceil.toLong)
490+
val amountToSend = (route.amount + additionalAmount).min(amountLeft)
491+
routes = route.copy(amount = amountToSend) :: routes
492+
amountLeft = amountLeft - amountToSend
493+
}
494+
}
495+
Right(routes)
496+
}
497+
}
498+
499+
private def optimizeExpectedValue(route: Seq[GraphEdge], capacity: MilliSatoshi, usedCapacity: mutable.Map[ShortChannelId, MilliSatoshi], usePastRelaysData: Boolean, balances: BalancesEstimates, now: TimestampSecond): MilliSatoshi = {
500+
// We search the maximum value of a polynomial between its two smallest roots (0 and the minimum channel capacity on the path).
501+
// We use binary search to find where the derivative changes sign.
502+
var low = 1L
503+
var high = capacity.toLong
504+
while (high - low > 1L) {
505+
val x = (high + low) / 2
506+
val d = route.drop(1).foldLeft(1.0 / x.toDouble) { case (total, edge) =>
507+
// We compute the success probability `p` for this edge, and its derivative `dp`.
508+
val (p, dp) = if (usePastRelaysData) {
509+
balances.get(edge).canSendAndDerivative(MilliSatoshi(x), now)
466510
} else {
467-
candidate.copy(amount = candidate.amount.min(amount))
511+
// If not using past relays data, we assume that balances are uniformly distributed between 0 and the full capacity of the channel.
512+
val c = (edge.capacity - usedCapacity.getOrElse(edge.desc.shortChannelId, 0 msat)).toLong.toDouble
513+
(1.0 - x.toDouble / c, -1.0 / c)
468514
}
469-
updateUsedCapacity(route, usedCapacity)
470-
// NB: we re-enqueue the current path, it may still have capacity for a second HTLC.
471-
split(amount - route.amount, paths.enqueue(current), usedCapacity, routeParams, route +: selectedRoutes)
515+
total + dp / p
516+
}
517+
if (d > 0.0) {
518+
low = x
519+
} else {
520+
high = x
472521
}
473522
}
523+
MilliSatoshi(high)
474524
}
475525

476526
/** Compute the maximum amount that we can send through the given route. */
477527
private def computeRouteMaxAmount(route: Seq[GraphEdge], usedCapacity: mutable.Map[ShortChannelId, MilliSatoshi]): Route = {
478-
val firstHopMaxAmount = route.head.maxHtlcAmount(usedCapacity.getOrElse(route.head.desc.shortChannelId, 0 msat))
528+
val firstHopMaxAmount = maxEdgeAmount(route.head, usedCapacity.getOrElse(route.head.desc.shortChannelId, 0 msat))
479529
val amount = route.drop(1).foldLeft(firstHopMaxAmount) { case (amount, edge) =>
480530
// We compute fees going forward instead of backwards. That means we will slightly overestimate the fees of some
481531
// edges, but we will always stay inside the capacity bounds we computed.
482532
val amountMinusFees = amount - edge.fee(amount)
483-
val edgeMaxAmount = edge.maxHtlcAmount(usedCapacity.getOrElse(edge.desc.shortChannelId, 0 msat))
533+
val edgeMaxAmount = maxEdgeAmount(edge, usedCapacity.getOrElse(edge.desc.shortChannelId, 0 msat))
484534
amountMinusFees.min(edgeMaxAmount)
485535
}
486536
Route(amount.max(0 msat), route.map(graphEdgeToHop), None)
487537
}
488538

539+
private def maxEdgeAmount(edge: GraphEdge, usedCapacity: MilliSatoshi): MilliSatoshi = {
540+
val maxBalance = edge.balance_opt.getOrElse(edge.params.htlcMaximum_opt.getOrElse(edge.capacity.toMilliSatoshi))
541+
Seq(Some(maxBalance - usedCapacity), edge.params.htlcMaximum_opt).flatten.min.max(0 msat)
542+
}
543+
489544
/** Initialize known used capacity based on pending HTLCs. */
490545
private def initializeUsedCapacity(pendingHtlcs: Seq[Route]): mutable.Map[ShortChannelId, MilliSatoshi] = {
491546
val usedCapacity = mutable.Map.empty[ShortChannelId, MilliSatoshi]
@@ -497,7 +552,14 @@ object RouteCalculation {
497552

498553
/** Update used capacity by taking into account an HTLC sent to the given route. */
499554
private def updateUsedCapacity(route: Route, usedCapacity: mutable.Map[ShortChannelId, MilliSatoshi]): Unit = {
500-
route.hops.foldRight(route.amount) { case (hop, amount) =>
555+
val finalHopAmount = route.finalHop_opt.map(hop => {
556+
hop match {
557+
case BlindedHop(dummyId, _) => usedCapacity.updateWith(dummyId)(previous => Some(route.amount + previous.getOrElse(0 msat)))
558+
case _: NodeHop => ()
559+
}
560+
route.amount + hop.fee(route.amount)
561+
}).getOrElse(route.amount)
562+
route.hops.foldRight(finalHopAmount) { case (hop, amount) =>
501563
usedCapacity.updateWith(hop.shortChannelId)(previous => Some(amount + previous.getOrElse(0 msat)))
502564
amount + hop.fee(amount)
503565
}

0 commit comments

Comments
 (0)