Skip to content

Commit c059503

Browse files
committed
WIP
1 parent 6bb007e commit c059503

File tree

5 files changed

+104
-82
lines changed

5 files changed

+104
-82
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/router/RouteCalculation.scala

Lines changed: 48 additions & 28 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
}
@@ -432,45 +425,72 @@ object RouteCalculation {
432425
routeParams.copy(mpp = MultiPartParams(minPartAmount, numRoutes, routeParams.mpp.splittingStrategy))
433426
}
434427
findRouteInternal(g, localNodeId, targetNodeId, routeParams1.mpp.minPartAmount, maxFee, routeParams1.mpp.maxParts, extraEdges, ignoredEdges, ignoredVertices, routeParams1, currentBlockHeight) match {
435-
case Right(routes) =>
428+
case Right(paths) =>
436429
// 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 {
430+
split(amount, mutable.Queue(paths: _*), initializeUsedCapacity(pendingHtlcs), routeParams1) match {
438431
case Right(routes) if validateMultiPartRoute(amount, maxFee, routes, routeParams.includeLocalChannelCost) => Right(routes)
432+
case Right(routes) if routeParams.randomize =>
433+
// We've found a multipart route, but it's too expensive. We try again without randomization to prioritize cheaper paths.
434+
val sortedPaths = paths.sortBy(_.weight.weight)
435+
split(amount, mutable.Queue(sortedPaths: _*), initializeUsedCapacity(pendingHtlcs), routeParams1) match {
436+
case Right(routes) if validateMultiPartRoute(amount, maxFee, routes, routeParams.includeLocalChannelCost) => Right(routes)
437+
case _ => Left(RouteNotFound)
438+
}
439439
case _ => Left(RouteNotFound)
440440
}
441441
case Left(ex) => Left(ex)
442442
}
443443
}
444444

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 {
445+
private case class CandidateRoute(routeWithMaximumAmount: Route, chosenAmount: MilliSatoshi)
446+
447+
private def split(amount: MilliSatoshi, paths: mutable.Queue[WeightedPath[PaymentPathWeight]], usedCapacity: mutable.Map[ShortChannelId, MilliSatoshi], routeParams: RouteParams): Either[RouterException, Seq[Route]] = {
448+
var amountLeft = amount
449+
var candidates: Seq[CandidateRoute] = Nil
450+
while(paths.nonEmpty && amountLeft > 0.msat) {
452451
val current = paths.dequeue()
453452
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 {
453+
if (candidate.amount >= routeParams.mpp.minPartAmount.min(amountLeft)) {
454+
val routeFullCapacity = candidate.copy(amount = candidate.amount.min(amountLeft))
455+
val chosenAmount = routeParams.mpp.splittingStrategy match {
459456
case MultiPartParams.Randomize =>
460457
// randomly choose the amount to be between 20% and 100% of the available capacity.
461458
val randomizedAmount = candidate.amount * ((20d + Random.nextInt(81)) / 100)
462-
candidate.copy(amount = randomizedAmount.max(routeParams.mpp.minPartAmount).min(amount))
459+
if (randomizedAmount < routeParams.mpp.minPartAmount) {
460+
routeParams.mpp.minPartAmount.min(amountLeft)
461+
} else {
462+
randomizedAmount.min(amountLeft)
463+
}
463464
case MultiPartParams.MaxExpectedAmount =>
464465
val bestAmount = optimizeExpectedValue(current.path, candidate.amount, usedCapacity)
465-
candidate.copy(amount = bestAmount.max(routeParams.mpp.minPartAmount).min(amount))
466+
bestAmount.max(routeParams.mpp.minPartAmount).min(amountLeft)
466467
case MultiPartParams.FullCapacity =>
467-
candidate.copy(amount = candidate.amount.min(amount))
468+
routeFullCapacity.amount
468469
}
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)
470+
updateUsedCapacity(routeFullCapacity, usedCapacity)
471+
candidates = CandidateRoute(routeFullCapacity, chosenAmount) +: candidates
472+
amountLeft = amountLeft - chosenAmount
473+
paths.enqueue(current)
472474
}
473475
}
476+
val totalChosen = candidates.map(_.chosenAmount).sum
477+
val totalMaximum = candidates.map(_.routeWithMaximumAmount.amount).sum
478+
if (totalMaximum < amount) {
479+
Left(RouteNotFound)
480+
} else {
481+
val additionalFraction = if (totalMaximum > totalChosen) (amount - totalChosen).toLong.toDouble / (totalMaximum - totalChosen).toLong.toDouble else 0.0
482+
var routes: List[Route] = Nil
483+
var amountLeft = amount
484+
candidates.foreach { case CandidateRoute(route, chosenAmount) =>
485+
if (amountLeft > 0.msat) {
486+
val additionalAmount = MilliSatoshi(((route.amount - chosenAmount).toLong * additionalFraction).ceil.toLong)
487+
val amountToSend = (chosenAmount + additionalAmount).min(amountLeft)
488+
routes = route.copy(amount = amountToSend) :: routes
489+
amountLeft = amountLeft - amountToSend
490+
}
491+
}
492+
Right(routes)
493+
}
474494
}
475495

476496
private def optimizeExpectedValue(route: Seq[GraphEdge], capacity: MilliSatoshi, usedCapacity: mutable.Map[ShortChannelId, MilliSatoshi]): MilliSatoshi = {

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

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import fr.acinq.eclair.router.Graph.GraphStructure.DirectedGraph.graphEdgeToHop
2626
import fr.acinq.eclair.router.Graph.GraphStructure.{DirectedGraph, GraphEdge}
2727
import fr.acinq.eclair.router.Graph.{HeuristicsConstants, PaymentPathWeight}
2828
import fr.acinq.eclair.router.RouteCalculation._
29+
import fr.acinq.eclair.router.Router.MultiPartParams.{FullCapacity, MaxExpectedAmount, Randomize}
2930
import fr.acinq.eclair.router.Router._
3031
import fr.acinq.eclair.transactions.Transactions
3132
import fr.acinq.eclair.wire.protocol._
@@ -766,6 +767,7 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution {
766767
.modify(_.boundaries.maxFeeFlat).setTo(7 msat)
767768
.modify(_.boundaries.maxFeeProportional).setTo(0)
768769
.modify(_.randomize).setTo(true)
770+
.modify(_.mpp.splittingStrategy).setTo(Randomize)
769771
val strictFee = strictFeeParams.getMaxFee(DEFAULT_AMOUNT_MSAT)
770772
assert(strictFee == 7.msat)
771773

@@ -914,7 +916,13 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution {
914916
checkRouteAmounts(routes, amount, 0 msat)
915917
}
916918
{
917-
val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = routeParams.copy(randomize = true), currentBlockHeight = BlockHeight(400000))
919+
val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = routeParams.copy(randomize = true).modify(_.mpp.splittingStrategy).setTo(Randomize), currentBlockHeight = BlockHeight(400000))
920+
assert(routes.length >= 4, routes)
921+
assert(routes.forall(_.hops.length == 1), routes)
922+
checkRouteAmounts(routes, amount, 0 msat)
923+
}
924+
{
925+
val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = routeParams.modify(_.mpp.splittingStrategy).setTo(MaxExpectedAmount), currentBlockHeight = BlockHeight(400000))
918926
assert(routes.length >= 4, routes)
919927
assert(routes.forall(_.hops.length == 1), routes)
920928
checkRouteAmounts(routes, amount, 0 msat)
@@ -977,7 +985,14 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution {
977985
checkRouteAmounts(routes, amount, 0 msat)
978986
}
979987
{
980-
val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true), currentBlockHeight = BlockHeight(400000))
988+
val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true).modify(_.mpp.splittingStrategy).setTo(Randomize), currentBlockHeight = BlockHeight(400000))
989+
assert(routes.length >= 3, routes)
990+
assert(routes.forall(_.hops.length == 1), routes)
991+
checkIgnoredChannels(routes, 2L)
992+
checkRouteAmounts(routes, amount, 0 msat)
993+
}
994+
{
995+
val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS.modify(_.mpp.splittingStrategy).setTo(MaxExpectedAmount), currentBlockHeight = BlockHeight(400000))
981996
assert(routes.length >= 3, routes)
982997
assert(routes.forall(_.hops.length == 1), routes)
983998
checkIgnoredChannels(routes, 2L)
@@ -1087,7 +1102,11 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution {
10871102
assert(result == Failure(RouteNotFound))
10881103
}
10891104
{
1090-
val result = findMultiPartRoute(g, a, b, 40000 msat, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true), currentBlockHeight = BlockHeight(400000))
1105+
val result = findMultiPartRoute(g, a, b, 40000 msat, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true).modify(_.mpp.splittingStrategy).setTo(Randomize), currentBlockHeight = BlockHeight(400000))
1106+
assert(result == Failure(RouteNotFound))
1107+
}
1108+
{
1109+
val result = findMultiPartRoute(g, a, b, 40000 msat, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS.modify(_.mpp.splittingStrategy).setTo(MaxExpectedAmount), currentBlockHeight = BlockHeight(400000))
10911110
assert(result == Failure(RouteNotFound))
10921111
}
10931112
}
@@ -1116,7 +1135,11 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution {
11161135
assert(result == Failure(RouteNotFound))
11171136
}
11181137
{
1119-
val result = findMultiPartRoute(g, a, b, 10000 msat, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true), currentBlockHeight = BlockHeight(400000))
1138+
val result = findMultiPartRoute(g, a, b, 10000 msat, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true).modify(_.mpp.splittingStrategy).setTo(Randomize), currentBlockHeight = BlockHeight(400000))
1139+
assert(result == Failure(RouteNotFound))
1140+
}
1141+
{
1142+
val result = findMultiPartRoute(g, a, b, 10000 msat, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS.modify(_.mpp.splittingStrategy).setTo(MaxExpectedAmount), currentBlockHeight = BlockHeight(400000))
11201143
assert(result == Failure(RouteNotFound))
11211144
}
11221145
}
@@ -1152,7 +1175,11 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution {
11521175
}
11531176
{
11541177
// Randomize routes.
1155-
val Success(routes) = findMultiPartRoute(g, a, e, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true), currentBlockHeight = BlockHeight(400000))
1178+
val Success(routes) = findMultiPartRoute(g, a, e, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true).modify(_.mpp.splittingStrategy).setTo(Randomize), currentBlockHeight = BlockHeight(400000))
1179+
checkRouteAmounts(routes, amount, maxFee)
1180+
}
1181+
{
1182+
val Success(routes) = findMultiPartRoute(g, a, e, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS.modify(_.mpp.splittingStrategy).setTo(MaxExpectedAmount), currentBlockHeight = BlockHeight(400000))
11561183
checkRouteAmounts(routes, amount, maxFee)
11571184
}
11581185
{
@@ -1242,7 +1269,11 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution {
12421269
}
12431270
{
12441271
// Randomize routes.
1245-
val Success(routes) = findMultiPartRoute(g, a, f, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true), currentBlockHeight = BlockHeight(400000))
1272+
val Success(routes) = findMultiPartRoute(g, a, f, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true).modify(_.mpp.splittingStrategy).setTo(Randomize), currentBlockHeight = BlockHeight(400000))
1273+
checkRouteAmounts(routes, amount, maxFee)
1274+
}
1275+
{
1276+
val Success(routes) = findMultiPartRoute(g, a, f, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS.modify(_.mpp.splittingStrategy).setTo(MaxExpectedAmount), currentBlockHeight = BlockHeight(400000))
12461277
checkRouteAmounts(routes, amount, maxFee)
12471278
}
12481279
{
@@ -1300,22 +1331,22 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution {
13001331
{
13011332
val amount = 15_000_000 msat
13021333
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, DEFAULT_ROUTE_PARAMS.mpp.splittingStrategy))
1334+
val routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = false, mpp = MultiPartParams(50_000 msat, 5, FullCapacity))
13041335
val Success(routes) = findMultiPartRoute(g, a, d, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000))
13051336
checkRouteAmounts(routes, amount, maxFee)
13061337
assert(routes2Ids(routes) == Set(Seq(100L, 101L)))
13071338
}
13081339
{
13091340
val amount = 15_000_000 msat
13101341
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, DEFAULT_ROUTE_PARAMS.mpp.splittingStrategy))
1342+
val routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = false, mpp = MultiPartParams(50_000 msat, 5, FullCapacity))
13121343
val failure = findMultiPartRoute(g, a, d, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000))
13131344
assert(failure == Failure(RouteNotFound))
13141345
}
13151346
{
13161347
val amount = 5_000_000 msat
13171348
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, DEFAULT_ROUTE_PARAMS.mpp.splittingStrategy))
1349+
val routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = false, mpp = MultiPartParams(50_000 msat, 5, FullCapacity))
13191350
val Success(routes) = findMultiPartRoute(g, a, d, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000))
13201351
assert(routes.length == 5)
13211352
routes.foreach(route => {
@@ -1429,7 +1460,12 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution {
14291460
}
14301461
{
14311462
val (amount, maxFee) = (40000 msat, 100 msat)
1432-
val Success(routes) = findMultiPartRoute(g, d, f, amount, maxFee, routeParams = routeParams.copy(randomize = true), currentBlockHeight = BlockHeight(400000))
1463+
val Success(routes) = findMultiPartRoute(g, d, f, amount, maxFee, routeParams = routeParams.copy(randomize = true).modify(_.mpp.splittingStrategy).setTo(Randomize), currentBlockHeight = BlockHeight(400000))
1464+
checkRouteAmounts(routes, amount, maxFee)
1465+
}
1466+
{
1467+
val (amount, maxFee) = (40000 msat, 100 msat)
1468+
val Success(routes) = findMultiPartRoute(g, d, f, amount, maxFee, routeParams = routeParams.modify(_.mpp.splittingStrategy).setTo(MaxExpectedAmount), currentBlockHeight = BlockHeight(400000))
14331469
checkRouteAmounts(routes, amount, maxFee)
14341470
}
14351471
{
@@ -1528,7 +1564,7 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution {
15281564
makeEdge(9L, c, f, Random.nextLong(250).msat, Random.nextInt(10000), minHtlc = Random.nextLong(100).msat, maxHtlc = Some((20000 + Random.nextLong(80000)).msat), CltvExpiryDelta(Random.nextInt(288)), capacity = (10 + Random.nextLong(100)).sat)
15291565
)), 1 day)
15301566

1531-
findMultiPartRoute(g, d, f, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true), currentBlockHeight = BlockHeight(400000)) match {
1567+
findMultiPartRoute(g, d, f, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true).modify(_.mpp.splittingStrategy).setTo(Randomize), currentBlockHeight = BlockHeight(400000)) match {
15321568
case Success(routes) => checkRouteAmounts(routes, amount, maxFee)
15331569
case Failure(ex) => assert(ex == RouteNotFound)
15341570
}
@@ -1949,7 +1985,7 @@ object RouteCalculationSpec {
19491985
randomize = false,
19501986
boundaries = SearchBoundaries(21000 msat, 0.03, 6, CltvExpiryDelta(2016)),
19511987
NO_WEIGHT_RATIOS,
1952-
MultiPartParams(1000 msat, 10, MultiPartParams.FullCapacity),
1988+
MultiPartParams(1000 msat, 10, FullCapacity),
19531989
experimentName = "my-test-experiment",
19541990
experimentPercentage = 100).getDefaultRouteParams
19551991

0 commit comments

Comments
 (0)