Skip to content

Commit d2d43a3

Browse files
committed
fixes
1 parent 6bb007e commit d2d43a3

File tree

8 files changed

+132
-101
lines changed

8 files changed

+132
-101
lines changed

docs/release-notes/eclair-vnext.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
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 times success probability).
812

913
### Remove support for legacy channel codecs
1014

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: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,8 @@ 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] = (

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/RouteCalculation.scala

Lines changed: 64 additions & 37 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 = {
@@ -432,62 +426,95 @@ object RouteCalculation {
432426
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(routes) 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(routeWithMaximumAmount: Route, chosenAmount: 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: Seq[CandidateRoute] = Nil
451+
while(paths.nonEmpty && amountLeft > 0.msat) {
452452
val current = paths.dequeue()
453453
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 = routeParams.mpp.splittingStrategy match {
454+
if (candidate.amount >= routeParams.mpp.minPartAmount.min(amountLeft)) {
455+
val routeFullCapacity = candidate.copy(amount = candidate.amount.min(amountLeft))
456+
val chosenAmount = routeParams.mpp.splittingStrategy match {
459457
case MultiPartParams.Randomize =>
460458
// randomly choose the amount to be between 20% and 100% of the available capacity.
461459
val randomizedAmount = candidate.amount * ((20d + Random.nextInt(81)) / 100)
462-
candidate.copy(amount = randomizedAmount.max(routeParams.mpp.minPartAmount).min(amount))
460+
if (randomizedAmount < routeParams.mpp.minPartAmount) {
461+
routeParams.mpp.minPartAmount.min(amountLeft)
462+
} else {
463+
randomizedAmount.min(amountLeft)
464+
}
463465
case MultiPartParams.MaxExpectedAmount =>
464-
val bestAmount = optimizeExpectedValue(current.path, candidate.amount, usedCapacity)
465-
candidate.copy(amount = bestAmount.max(routeParams.mpp.minPartAmount).min(amount))
466+
val bestAmount = optimizeExpectedValue(current.path, candidate.amount, usedCapacity, routeParams.heuristics.usePastRelaysData, balances, now)
467+
bestAmount.max(routeParams.mpp.minPartAmount).min(amountLeft)
466468
case MultiPartParams.FullCapacity =>
467-
candidate.copy(amount = candidate.amount.min(amount))
469+
routeFullCapacity.amount
468470
}
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)
471+
updateUsedCapacity(routeFullCapacity, usedCapacity)
472+
candidates = CandidateRoute(routeFullCapacity, chosenAmount) +: candidates
473+
amountLeft = amountLeft - chosenAmount
474+
paths.enqueue(current)
472475
}
473476
}
477+
val totalChosen = candidates.map(_.chosenAmount).sum
478+
val totalMaximum = candidates.map(_.routeWithMaximumAmount.amount).sum
479+
if (totalMaximum < amount) {
480+
Left(RouteNotFound)
481+
} else {
482+
val additionalFraction = if (totalMaximum > totalChosen) (amount - totalChosen).toLong.toDouble / (totalMaximum - totalChosen).toLong.toDouble else 0.0
483+
var routes: List[Route] = Nil
484+
var amountLeft = amount
485+
candidates.foreach { case CandidateRoute(route, chosenAmount) =>
486+
if (amountLeft > 0.msat) {
487+
val additionalAmount = MilliSatoshi(((route.amount - chosenAmount).toLong * additionalFraction).ceil.toLong)
488+
val amountToSend = (chosenAmount + additionalAmount).min(amountLeft)
489+
routes = route.copy(amount = amountToSend) :: routes
490+
amountLeft = amountLeft - amountToSend
491+
}
492+
}
493+
Right(routes)
494+
}
474495
}
475496

476-
private def optimizeExpectedValue(route: Seq[GraphEdge], capacity: MilliSatoshi, usedCapacity: mutable.Map[ShortChannelId, MilliSatoshi]): MilliSatoshi = {
497+
private def optimizeExpectedValue(route: Seq[GraphEdge], capacity: MilliSatoshi, usedCapacity: mutable.Map[ShortChannelId, MilliSatoshi], usePastRelaysData: Boolean, balances: BalancesEstimates, now: TimestampSecond): MilliSatoshi = {
477498
// We search the maximum value of a polynomial between its two smallest roots (0 and the minimum channel capacity on the path).
478499
// We use binary search to find where the derivative changes sign.
479500
var low = 1L
480501
var high = capacity.toLong
481502
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)
503+
val x = (high + low) / 2
504+
val d = route.drop(1).foldLeft(1.0 / x.toDouble) { case (total, edge) =>
505+
// We compute the success probability `p` for this edge, and its derivative `dp`.
506+
val (p, dp) = if (usePastRelaysData) {
507+
balances.get(edge).canSendAndDerivative(MilliSatoshi(x), now)
508+
} else {
509+
val c = (edge.capacity - usedCapacity.getOrElse(edge.desc.shortChannelId, 0 msat)).toLong.toDouble
510+
(1.0 - x.toDouble / c, -1.0 / c)
511+
}
512+
total + dp / p
486513
}
487514
if (d > 0.0) {
488-
low = mid
515+
low = x
489516
} else {
490-
high = mid
517+
high = x
491518
}
492519
}
493520
MilliSatoshi(high)

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import fr.acinq.eclair.payment.send.Recipient
3838
import fr.acinq.eclair.payment.{Bolt11Invoice, Invoice}
3939
import fr.acinq.eclair.remote.EclairInternalsSerializer.RemoteTypes
4040
import fr.acinq.eclair.router.Graph.GraphStructure.DirectedGraph
41-
import fr.acinq.eclair.router.Graph.MessageWeightRatios
41+
import fr.acinq.eclair.router.Graph.{HeuristicsConstants, MessageWeightRatios}
4242
import fr.acinq.eclair.router.Monitoring.Metrics
4343
import fr.acinq.eclair.wire.protocol._
4444

@@ -354,7 +354,7 @@ object Router {
354354

355355
case class PathFindingConf(randomize: Boolean,
356356
boundaries: SearchBoundaries,
357-
heuristics: Graph.WeightRatios[Graph.PaymentPathWeight],
357+
heuristics: HeuristicsConstants,
358358
mpp: MultiPartParams,
359359
experimentName: String,
360360
experimentPercentage: Int) {
@@ -594,7 +594,7 @@ object Router {
594594

595595
case class RouteParams(randomize: Boolean,
596596
boundaries: SearchBoundaries,
597-
heuristics: Graph.WeightRatios[Graph.PaymentPathWeight],
597+
heuristics: HeuristicsConstants,
598598
mpp: MultiPartParams,
599599
experimentName: String,
600600
includeLocalChannelCost: Boolean) {

0 commit comments

Comments
 (0)