From 6bb007eab854dadc99023dadc87c368644f12e1b Mon Sep 17 00:00:00 2001 From: Thomas HUET Date: Tue, 5 Dec 2023 16:03:37 +0100 Subject: [PATCH 1/6] Split MPP by maximizing expected delivered amount As suggested by @renepickhardt in https://github.com/ACINQ/eclair/pull/2785 --- eclair-core/src/main/resources/reference.conf | 1 + .../scala/fr/acinq/eclair/NodeParams.scala | 7 +- .../remote/EclairInternalsSerializer.scala | 12 +++- .../eclair/router/RouteCalculation.scala | 42 +++++++++--- .../scala/fr/acinq/eclair/router/Router.scala | 15 +++- .../scala/fr/acinq/eclair/TestConstants.scala | 2 + .../eclair/integration/IntegrationSpec.scala | 2 +- .../MultiPartPaymentLifecycleSpec.scala | 2 +- .../eclair/payment/PaymentLifecycleSpec.scala | 2 +- .../eclair/router/RouteCalculationSpec.scala | 68 ++++++++++++++++--- 10 files changed, 127 insertions(+), 26 deletions(-) diff --git a/eclair-core/src/main/resources/reference.conf b/eclair-core/src/main/resources/reference.conf index 55a1041c93..a6c7b837bd 100644 --- a/eclair-core/src/main/resources/reference.conf +++ b/eclair-core/src/main/resources/reference.conf @@ -461,6 +461,7 @@ eclair { mpp { min-amount-satoshis = 15000 // minimum amount sent via partial HTLCs max-parts = 5 // maximum number of HTLCs sent per payment: increasing this value will impact performance + splitting-strategy = "randomize" // Can be either "full-capacity", "randomize" or "max-expected-amount" } boundaries { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala index 733155ba52..e824e684dd 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala @@ -468,7 +468,12 @@ object NodeParams extends Logging { usePastRelaysData = config.getBoolean("use-past-relay-data")), mpp = MultiPartParams( Satoshi(config.getLong("mpp.min-amount-satoshis")).toMilliSatoshi, - config.getInt("mpp.max-parts")), + config.getInt("mpp.max-parts"), + config.getString("mpp.splitting-strategy") match { + case "full-capacity" => MultiPartParams.FullCapacity + case "randomize" => MultiPartParams.Randomize + case "max-expected-amount" => MultiPartParams.MaxExpectedAmount + }), experimentName = name, experimentPercentage = config.getInt("percentage")) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/remote/EclairInternalsSerializer.scala b/eclair-core/src/main/scala/fr/acinq/eclair/remote/EclairInternalsSerializer.scala index 10336d00a8..10f780db78 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/remote/EclairInternalsSerializer.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/remote/EclairInternalsSerializer.scala @@ -70,7 +70,17 @@ object EclairInternalsSerializer { val multiPartParamsCodec: Codec[MultiPartParams] = ( ("minPartAmount" | millisatoshi) :: - ("maxParts" | int32)).as[MultiPartParams] + ("maxParts" | int32) :: + ("splittingStrategy" | int8.narrow[MultiPartParams.SplittingStrategy]({ + case 0 => Attempt.successful(MultiPartParams.FullCapacity) + case 1 => Attempt.successful(MultiPartParams.Randomize) + case 2 => Attempt.successful(MultiPartParams.MaxExpectedAmount) + case n => Attempt.failure(Err(s"Invalid value $n")) + }, { + case MultiPartParams.FullCapacity => 0 + case MultiPartParams.Randomize => 1 + case MultiPartParams.MaxExpectedAmount => 2 + }))).as[MultiPartParams] val pathFindingConfCodec: Codec[PathFindingConf] = ( ("randomize" | bool(8)) :: diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/router/RouteCalculation.scala b/eclair-core/src/main/scala/fr/acinq/eclair/router/RouteCalculation.scala index dd03794d40..9d6ac45bdd 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/router/RouteCalculation.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/router/RouteCalculation.scala @@ -429,7 +429,7 @@ object RouteCalculation { // We want to ensure that the set of routes we find have enough capacity to allow sending the total amount, // without excluding routes with small capacity when the total amount is small. val minPartAmount = routeParams.mpp.minPartAmount.max(amount / numRoutes).min(amount) - routeParams.copy(mpp = MultiPartParams(minPartAmount, numRoutes)) + routeParams.copy(mpp = MultiPartParams(minPartAmount, numRoutes, routeParams.mpp.splittingStrategy)) } findRouteInternal(g, localNodeId, targetNodeId, routeParams1.mpp.minPartAmount, maxFee, routeParams1.mpp.maxParts, extraEdges, ignoredEdges, ignoredVertices, routeParams1, currentBlockHeight) match { case Right(routes) => @@ -455,16 +455,16 @@ object RouteCalculation { // this route doesn't have enough capacity left: we remove it and continue. split(amount, paths, usedCapacity, routeParams, selectedRoutes) } else { - val route = if (routeParams.randomize) { - // randomly choose the amount to be between 20% and 100% of the available capacity. - val randomizedAmount = candidate.amount * ((20d + Random.nextInt(81)) / 100) - if (randomizedAmount < routeParams.mpp.minPartAmount) { - candidate.copy(amount = routeParams.mpp.minPartAmount.min(amount)) - } else { - candidate.copy(amount = randomizedAmount.min(amount)) - } - } else { - candidate.copy(amount = candidate.amount.min(amount)) + val route = routeParams.mpp.splittingStrategy match { + case MultiPartParams.Randomize => + // randomly choose the amount to be between 20% and 100% of the available capacity. + val randomizedAmount = candidate.amount * ((20d + Random.nextInt(81)) / 100) + candidate.copy(amount = randomizedAmount.max(routeParams.mpp.minPartAmount).min(amount)) + case MultiPartParams.MaxExpectedAmount => + val bestAmount = optimizeExpectedValue(current.path, candidate.amount, usedCapacity) + candidate.copy(amount = bestAmount.max(routeParams.mpp.minPartAmount).min(amount)) + case MultiPartParams.FullCapacity => + candidate.copy(amount = candidate.amount.min(amount)) } updateUsedCapacity(route, usedCapacity) // NB: we re-enqueue the current path, it may still have capacity for a second HTLC. @@ -473,6 +473,26 @@ object RouteCalculation { } } + private def optimizeExpectedValue(route: Seq[GraphEdge], capacity: MilliSatoshi, usedCapacity: mutable.Map[ShortChannelId, MilliSatoshi]): MilliSatoshi = { + // We search the maximum value of a polynomial between its two smallest roots (0 and the minimum channel capacity on the path). + // We use binary search to find where the derivative changes sign. + var low = 1L + var high = capacity.toLong + while (high - low > 1L) { + val mid = (high + low) / 2 + val d = route.drop(1).foldLeft(1.0 / mid) { case (x, edge) => + val availableCapacity = edge.capacity - usedCapacity.getOrElse(edge.desc.shortChannelId, 0 msat) + x - 1.0 / (availableCapacity.toLong - mid) + } + if (d > 0.0) { + low = mid + } else { + high = mid + } + } + MilliSatoshi(high) + } + /** Compute the maximum amount that we can send through the given route. */ private def computeRouteMaxAmount(route: Seq[GraphEdge], usedCapacity: mutable.Map[ShortChannelId, MilliSatoshi]): Route = { val firstHopMaxAmount = route.head.maxHtlcAmount(usedCapacity.getOrElse(route.head.desc.shortChannelId, 0 msat)) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/router/Router.scala b/eclair-core/src/main/scala/fr/acinq/eclair/router/Router.scala index 479dd5a052..c748302aa0 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/router/Router.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/router/Router.scala @@ -577,7 +577,20 @@ object Router { override def fee(amount: MilliSatoshi): MilliSatoshi = fee } - case class MultiPartParams(minPartAmount: MilliSatoshi, maxParts: Int) + object MultiPartParams { + sealed trait SplittingStrategy + + /** Send the full capacity of the route */ + object FullCapacity extends SplittingStrategy + + /** Send between 20% and 100% of the capacity of the route */ + object Randomize extends SplittingStrategy + + /** Maximize the expected delivered amount */ + object MaxExpectedAmount extends SplittingStrategy + } + + case class MultiPartParams(minPartAmount: MilliSatoshi, maxParts: Int, splittingStrategy: MultiPartParams.SplittingStrategy) case class RouteParams(randomize: Boolean, boundaries: SearchBoundaries, diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala index e96cec01b4..433d97a3ff 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala @@ -231,6 +231,7 @@ object TestConstants { mpp = MultiPartParams( minPartAmount = 15000000 msat, maxParts = 10, + splittingStrategy = MultiPartParams.FullCapacity ), experimentName = "alice-test-experiment", experimentPercentage = 100))), @@ -422,6 +423,7 @@ object TestConstants { mpp = MultiPartParams( minPartAmount = 15000000 msat, maxParts = 10, + splittingStrategy = MultiPartParams.FullCapacity ), experimentName = "bob-test-experiment", experimentPercentage = 100))), diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/integration/IntegrationSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/integration/IntegrationSpec.scala index a078ec67a5..be17b9736b 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/integration/IntegrationSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/integration/IntegrationSpec.scala @@ -64,7 +64,7 @@ abstract class IntegrationSpec extends TestKitBaseClass with BitcoindService wit useLogProbability = false, usePastRelaysData = false ), - mpp = MultiPartParams(15000000 msat, 6), + mpp = MultiPartParams(15000000 msat, 6, MultiPartParams.FullCapacity), experimentName = "my-test-experiment", experimentPercentage = 100 ).getDefaultRouteParams diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/MultiPartPaymentLifecycleSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/MultiPartPaymentLifecycleSpec.scala index 66d3486a75..ececd0b3af 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/MultiPartPaymentLifecycleSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/MultiPartPaymentLifecycleSpec.scala @@ -697,7 +697,7 @@ object MultiPartPaymentLifecycleSpec { 6, CltvExpiryDelta(1008)), HeuristicsConstants(0, RelayFees(0 msat, 0), RelayFees(0 msat, 0), useLogProbability = false, usePastRelaysData = false), - MultiPartParams(1000 msat, 5), + MultiPartParams(1000 msat, 5, MultiPartParams.FullCapacity), experimentName = "my-test-experiment", experimentPercentage = 100 ).getDefaultRouteParams diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentLifecycleSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentLifecycleSpec.scala index 58b63fd6c2..101c8bc12f 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentLifecycleSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentLifecycleSpec.scala @@ -302,7 +302,7 @@ class PaymentLifecycleSpec extends BaseRouterSpec { randomize = false, boundaries = SearchBoundaries(100 msat, 0.0, 20, CltvExpiryDelta(2016)), HeuristicsConstants(0, RelayFees(0 msat, 0), RelayFees(0 msat, 0), useLogProbability = false, usePastRelaysData = false), - MultiPartParams(10_000 msat, 5), + MultiPartParams(10_000 msat, 5, MultiPartParams.FullCapacity), "my-test-experiment", experimentPercentage = 100 ).getDefaultRouteParams diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/router/RouteCalculationSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/router/RouteCalculationSpec.scala index 470a090815..344e910151 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/router/RouteCalculationSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/router/RouteCalculationSpec.scala @@ -905,7 +905,7 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution { makeEdge(4L, a, b, 100 msat, 20, minHtlc = 1 msat, balance_opt = Some(16000 msat)), )), 1 day) // We set max-parts to 3, but it should be ignored when sending to a direct neighbor. - val routeParams = DEFAULT_ROUTE_PARAMS.copy(mpp = MultiPartParams(2500 msat, 3)) + val routeParams = DEFAULT_ROUTE_PARAMS.copy(mpp = MultiPartParams(2500 msat, 3, DEFAULT_ROUTE_PARAMS.mpp.splittingStrategy)) { val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = routeParams, currentBlockHeight = BlockHeight(400000)) @@ -921,7 +921,7 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution { } { // We set min-part-amount to a value that excludes channels 1 and 4. - val failure = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = routeParams.copy(mpp = MultiPartParams(16500 msat, 3)), currentBlockHeight = BlockHeight(400000)) + val failure = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = routeParams.copy(mpp = MultiPartParams(16500 msat, 3, routeParams.mpp.splittingStrategy)), currentBlockHeight = BlockHeight(400000)) assert(failure == Failure(RouteNotFound)) } } @@ -1048,7 +1048,7 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution { )), 1 day) val amount = 30000 msat - val routeParams = DEFAULT_ROUTE_PARAMS.copy(mpp = MultiPartParams(2500 msat, 5)) + val routeParams = DEFAULT_ROUTE_PARAMS.copy(mpp = MultiPartParams(2500 msat, 5, DEFAULT_ROUTE_PARAMS.mpp.splittingStrategy)) val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = routeParams, currentBlockHeight = BlockHeight(400000)) assert(routes.forall(_.hops.length == 1), routes) assert(routes.length == 3, routes) @@ -1180,7 +1180,7 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution { // | | // +--- B --- D ---+ // Our balance and the amount we want to send are below the minimum part amount. - val routeParams = DEFAULT_ROUTE_PARAMS.copy(mpp = MultiPartParams(5000 msat, 5)) + val routeParams = DEFAULT_ROUTE_PARAMS.copy(mpp = MultiPartParams(5000 msat, 5, DEFAULT_ROUTE_PARAMS.mpp.splittingStrategy)) val g = GraphWithBalanceEstimates(DirectedGraph(List( makeEdge(1L, a, b, 50 msat, 100, minHtlc = 1 msat, balance_opt = Some(1500 msat)), makeEdge(2L, b, d, 15 msat, 0, minHtlc = 1 msat, capacity = 25 sat), @@ -1300,7 +1300,7 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution { { val amount = 15_000_000 msat val maxFee = 50_000 msat // this fee is enough to go through the preferred route - val routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = false, mpp = MultiPartParams(50_000 msat, 5)) + val routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = false, mpp = MultiPartParams(50_000 msat, 5, DEFAULT_ROUTE_PARAMS.mpp.splittingStrategy)) val Success(routes) = findMultiPartRoute(g, a, d, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000)) checkRouteAmounts(routes, amount, maxFee) assert(routes2Ids(routes) == Set(Seq(100L, 101L))) @@ -1308,14 +1308,14 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution { { val amount = 15_000_000 msat val maxFee = 10_000 msat // this fee is too low to go through the preferred route - val routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = false, mpp = MultiPartParams(50_000 msat, 5)) + val routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = false, mpp = MultiPartParams(50_000 msat, 5, DEFAULT_ROUTE_PARAMS.mpp.splittingStrategy)) val failure = findMultiPartRoute(g, a, d, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000)) assert(failure == Failure(RouteNotFound)) } { val amount = 5_000_000 msat val maxFee = 10_000 msat // this fee is enough to go through the preferred route, but the cheaper ones can handle it - val routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = false, mpp = MultiPartParams(50_000 msat, 5)) + val routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = false, mpp = MultiPartParams(50_000 msat, 5, DEFAULT_ROUTE_PARAMS.mpp.splittingStrategy)) val Success(routes) = findMultiPartRoute(g, a, d, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000)) assert(routes.length == 5) routes.foreach(route => { @@ -1405,7 +1405,7 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution { makeEdge(6L, b, c, 5 msat, 50, minHtlc = 1000 msat, capacity = 20 sat), makeEdge(7L, c, f, 5 msat, 10, minHtlc = 1500 msat, capacity = 50 sat) )), 1 day) - val routeParams = DEFAULT_ROUTE_PARAMS.copy(mpp = MultiPartParams(1500 msat, 10)) + val routeParams = DEFAULT_ROUTE_PARAMS.copy(mpp = MultiPartParams(1500 msat, 10, DEFAULT_ROUTE_PARAMS.mpp.splittingStrategy)) { val (amount, maxFee) = (15000 msat, 50 msat) @@ -1535,6 +1535,56 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution { } } + test("calculate multipart route to remote node using max expected amount splitting strategy") { + // A-------------E + // | | + // +----- B -----+ + // | | + // +----- C ---- + + // | | + // +----- D -----+ + val (amount, maxFee) = (60000 msat, 1000 msat) + val g = GraphWithBalanceEstimates(DirectedGraph(List( + // The A -> B -> E route is the most economic one, but we already have a pending HTLC in it. + makeEdge(0L, a, e, 50 msat, 0, minHtlc = 100 msat, balance_opt = Some(10000 msat)), + makeEdge(1L, a, b, 50 msat, 0, minHtlc = 100 msat, balance_opt = Some(100000 msat)), + makeEdge(2L, b, e, 50 msat, 0, minHtlc = 100 msat, capacity = 50 sat), + makeEdge(3L, a, c, 50 msat, 0, minHtlc = 100 msat, balance_opt = Some(100000 msat)), + makeEdge(4L, c, e, 50 msat, 0, minHtlc = 100 msat, capacity = 25 sat), + makeEdge(5L, a, d, 50 msat, 0, minHtlc = 100 msat, balance_opt = Some(100000 msat)), + makeEdge(6L, d, e, 50 msat, 0, minHtlc = 100 msat, capacity = 25 sat), + )), 1 day) + + val routeParams = DEFAULT_ROUTE_PARAMS.copy(mpp = DEFAULT_ROUTE_PARAMS.mpp.copy(splittingStrategy = MultiPartParams.MaxExpectedAmount)) + val Success(routes) = findMultiPartRoute(g, a, e, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000)) + checkRouteAmounts(routes, amount, maxFee) + assert(routes.map(route => (route.amount, route.hops.head.shortChannelId.toLong)).toSet == Set((10000 msat, 0L), (25000 msat, 1L), (12500 msat, 3L), (12500 msat, 5L))) + } + + test("calculate multipart route to remote node using max expected amount splitting strategy, respect minPartAmount") { + // +----- B -----+ + // | | + // A----- C ---- E + // | | + // +----- D -----+ + val (amount, maxFee) = (55000 msat, 1000 msat) + val g = GraphWithBalanceEstimates(DirectedGraph(List( + // The A -> B -> E route is the most economic one, but we already have a pending HTLC in it. + makeEdge(1L, a, b, 50 msat, 0, minHtlc = 100 msat, balance_opt = Some(100000 msat)), + makeEdge(2L, b, e, 50 msat, 0, minHtlc = 100 msat, capacity = 50 sat), + makeEdge(3L, a, c, 50 msat, 0, minHtlc = 100 msat, balance_opt = Some(100000 msat)), + makeEdge(4L, c, e, 50 msat, 0, minHtlc = 100 msat, capacity = 25 sat), + makeEdge(5L, a, d, 50 msat, 0, minHtlc = 100 msat, balance_opt = Some(100000 msat)), + makeEdge(6L, d, e, 50 msat, 0, minHtlc = 100 msat, capacity = 25 sat), + )), 1 day) + + val routeParams = DEFAULT_ROUTE_PARAMS.copy(mpp = DEFAULT_ROUTE_PARAMS.mpp.copy(minPartAmount = 15000 msat, splittingStrategy = MultiPartParams.MaxExpectedAmount)) + val Success(routes) = findMultiPartRoute(g, a, e, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000)) + assert(routes.forall(_.hops.length == 2), routes) + checkRouteAmounts(routes, amount, maxFee) + assert(routes.map(route => (route.amount, route.hops.head.shortChannelId.toLong)).toSet == Set((25000 msat, 1L), (15000 msat, 3L), (15000 msat, 5L))) + } + test("loop trap") { // +-----------------+ // | | @@ -1899,7 +1949,7 @@ object RouteCalculationSpec { randomize = false, boundaries = SearchBoundaries(21000 msat, 0.03, 6, CltvExpiryDelta(2016)), NO_WEIGHT_RATIOS, - MultiPartParams(1000 msat, 10), + MultiPartParams(1000 msat, 10, MultiPartParams.FullCapacity), experimentName = "my-test-experiment", experimentPercentage = 100).getDefaultRouteParams From 3e47d3e582cbd210326e1eb018f89ca80af92c20 Mon Sep 17 00:00:00 2001 From: Thomas HUET Date: Wed, 20 Nov 2024 17:08:01 +0100 Subject: [PATCH 2/6] fixes --- docs/release-notes/eclair-vnext.md | 6 +- .../send/MultiPartPaymentLifecycle.scala | 3 +- .../remote/EclairInternalsSerializer.scala | 4 +- .../acinq/eclair/router/BalanceEstimate.scala | 12 ++- .../eclair/router/RouteCalculation.scala | 101 +++++++++++------- .../scala/fr/acinq/eclair/router/Router.scala | 6 +- .../eclair/router/RouteCalculationSpec.scala | 60 ++++++++--- .../fr/acinq/eclair/router/RouterSpec.scala | 41 +------ 8 files changed, 132 insertions(+), 101 deletions(-) diff --git a/docs/release-notes/eclair-vnext.md b/docs/release-notes/eclair-vnext.md index ddd4e53bda..f1bdc9f01b 100644 --- a/docs/release-notes/eclair-vnext.md +++ b/docs/release-notes/eclair-vnext.md @@ -4,7 +4,11 @@ ## Major changes - +### New MPP splitting strategy + +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`). +These splitting strategies are now specified using `mpp.splitting-strategy = "full-capacity"` or `mpp.splitting-strategy = "randomize"`. +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). ### Remove support for legacy channel codecs diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/MultiPartPaymentLifecycle.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/MultiPartPaymentLifecycle.scala index 0f225a4414..b486e22f2c 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/MultiPartPaymentLifecycle.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/MultiPartPaymentLifecycle.scala @@ -27,6 +27,7 @@ import fr.acinq.eclair.payment.PaymentSent.PartialPayment import fr.acinq.eclair.payment._ import fr.acinq.eclair.payment.send.PaymentInitiator.SendPaymentConfig import fr.acinq.eclair.payment.send.PaymentLifecycle.SendPaymentToRoute +import fr.acinq.eclair.router.Router.MultiPartParams.FullCapacity import fr.acinq.eclair.router.Router._ import fr.acinq.eclair.{FSMDiagnosticActorLogging, Logs, MilliSatoshiLong, NodeParams, TimestampMilli} import scodec.bits.ByteVector @@ -57,7 +58,7 @@ class MultiPartPaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, when(WAIT_FOR_PAYMENT_REQUEST) { case Event(r: SendMultiPartPayment, _) => - val routeParams = r.routeParams.copy(randomize = false) // we don't randomize the first attempt, regardless of configuration choices + 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 log.debug("sending {} with maximum fee {}", r.recipient.totalAmount, r.routeParams.getMaxFee(r.recipient.totalAmount)) val d = PaymentProgress(r, r.maxAttempts, Map.empty, Ignore.empty, retryRouteRequest = false, failures = Nil) router ! createRouteRequest(self, nodeParams, routeParams, d, cfg) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/remote/EclairInternalsSerializer.scala b/eclair-core/src/main/scala/fr/acinq/eclair/remote/EclairInternalsSerializer.scala index 10f780db78..e3cbe16920 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/remote/EclairInternalsSerializer.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/remote/EclairInternalsSerializer.scala @@ -64,8 +64,8 @@ object EclairInternalsSerializer { ("useLogProbability" | bool(8)) :: ("usePastRelaysData" | bool(8))).as[HeuristicsConstants] - val weightRatiosCodec: Codec[WeightRatios[PaymentPathWeight]] = - discriminated[WeightRatios[PaymentPathWeight]].by(uint8) + val weightRatiosCodec: Codec[HeuristicsConstants] = + discriminated[HeuristicsConstants].by(uint8) .typecase(0xff, heuristicsConstantsCodec) val multiPartParamsCodec: Codec[MultiPartParams] = ( diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/router/BalanceEstimate.scala b/eclair-core/src/main/scala/fr/acinq/eclair/router/BalanceEstimate.scala index 7a492e2d11..f59a003a29 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/router/BalanceEstimate.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/router/BalanceEstimate.scala @@ -216,7 +216,7 @@ case class BalanceEstimate private(low: MilliSatoshi, * - probability that it can relay a payment of high is decay(high, 0, highTimestamp) which is close to 0 if highTimestamp is recent * - probability that it can relay a payment of maxCapacity is 0 */ - def canSend(amount: MilliSatoshi, now: TimestampSecond): Double = { + def canSendAndDerivative(amount: MilliSatoshi, now: TimestampSecond): (Double, Double) = { val a = amount.toLong.toDouble val l = low.toLong.toDouble val h = high.toLong.toDouble @@ -227,15 +227,17 @@ case class BalanceEstimate private(low: MilliSatoshi, val pHigh = decay(high, 0, highTimestamp, now) if (amount < low) { - (l - a * (1.0 - pLow)) / l + ((l - a * (1.0 - pLow)) / l, (pLow - 1.0) / l) } else if (amount < high) { - ((h - a) * pLow + (a - l) * pHigh) / (h - l) + (((h - a) * pLow + (a - l) * pHigh) / (h - l), (pHigh - pLow) / (h - l)) } else if (h < c) { - ((c - a) * pHigh) / (c - h) + (((c - a) * pHigh) / (c - h), (-pHigh) / (c - h)) } else { - 0 + (0.0, 0.0) } } + + def canSend(amount: MilliSatoshi, now: TimestampSecond): Double = canSendAndDerivative(amount, now)._1 } object BalanceEstimate { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/router/RouteCalculation.scala b/eclair-core/src/main/scala/fr/acinq/eclair/router/RouteCalculation.scala index 9d6ac45bdd..1481a37330 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/router/RouteCalculation.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/router/RouteCalculation.scala @@ -390,14 +390,7 @@ object RouteCalculation { pendingHtlcs: Seq[Route] = Nil, routeParams: RouteParams, currentBlockHeight: BlockHeight): Try[Seq[Route]] = Try { - val result = findMultiPartRouteInternal(g, localNodeId, targetNodeId, amount, maxFee, extraEdges, ignoredEdges, ignoredVertices, pendingHtlcs, routeParams, currentBlockHeight) match { - case Right(routes) => Right(routes) - case Left(RouteNotFound) if routeParams.randomize => - // If we couldn't find a randomized solution, fallback to a deterministic one. - findMultiPartRouteInternal(g, localNodeId, targetNodeId, amount, maxFee, extraEdges, ignoredEdges, ignoredVertices, pendingHtlcs, routeParams.copy(randomize = false), currentBlockHeight) - case Left(ex) => Left(ex) - } - result match { + findMultiPartRouteInternal(g, localNodeId, targetNodeId, amount, maxFee, extraEdges, ignoredEdges, ignoredVertices, pendingHtlcs, routeParams, currentBlockHeight) match { case Right(routes) => routes case Left(ex) => return Failure(ex) } @@ -413,7 +406,8 @@ object RouteCalculation { ignoredVertices: Set[PublicKey] = Set.empty, pendingHtlcs: Seq[Route] = Nil, routeParams: RouteParams, - currentBlockHeight: BlockHeight): Either[RouterException, Seq[Route]] = { + currentBlockHeight: BlockHeight, + now: TimestampSecond = TimestampSecond.now()): Either[RouterException, Seq[Route]] = { // We use Yen's k-shortest paths to find many paths for chunks of the total amount. // When the recipient is a direct peer, we have complete visibility on our local channels so we can use more accurate MPP parameters. val routeParams1 = { @@ -432,62 +426,95 @@ object RouteCalculation { routeParams.copy(mpp = MultiPartParams(minPartAmount, numRoutes, routeParams.mpp.splittingStrategy)) } findRouteInternal(g, localNodeId, targetNodeId, routeParams1.mpp.minPartAmount, maxFee, routeParams1.mpp.maxParts, extraEdges, ignoredEdges, ignoredVertices, routeParams1, currentBlockHeight) match { - case Right(routes) => + case Right(paths) => // We use these shortest paths to find a set of non-conflicting HTLCs that send the total amount. - split(amount, mutable.Queue(routes: _*), initializeUsedCapacity(pendingHtlcs), routeParams1) match { + split(amount, mutable.Queue(paths: _*), initializeUsedCapacity(pendingHtlcs), routeParams1, g.balances, now) match { case Right(routes) if validateMultiPartRoute(amount, maxFee, routes, routeParams.includeLocalChannelCost) => Right(routes) + case Right(_) if routeParams.randomize => + // We've found a multipart route, but it's too expensive. We try again without randomization to prioritize cheaper paths. + val sortedPaths = paths.sortBy(_.weight.weight) + split(amount, mutable.Queue(sortedPaths: _*), initializeUsedCapacity(pendingHtlcs), routeParams1, g.balances, now) match { + case Right(routes) if validateMultiPartRoute(amount, maxFee, routes, routeParams.includeLocalChannelCost) => Right(routes) + case _ => Left(RouteNotFound) + } case _ => Left(RouteNotFound) } case Left(ex) => Left(ex) } } - @tailrec - 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]] = { - if (amount == 0.msat) { - Right(selectedRoutes) - } else if (paths.isEmpty) { - Left(RouteNotFound) - } else { + private case class CandidateRoute(routeWithMaximumAmount: Route, chosenAmount: MilliSatoshi) + + 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]] = { + var amountLeft = amount + var candidates: List[CandidateRoute] = Nil + while(paths.nonEmpty && amountLeft > 0.msat) { val current = paths.dequeue() val candidate = computeRouteMaxAmount(current.path, usedCapacity) - if (candidate.amount < routeParams.mpp.minPartAmount.min(amount)) { - // this route doesn't have enough capacity left: we remove it and continue. - split(amount, paths, usedCapacity, routeParams, selectedRoutes) - } else { - val route = routeParams.mpp.splittingStrategy match { + if (candidate.amount >= routeParams.mpp.minPartAmount.min(amountLeft)) { + val routeFullCapacity = candidate.copy(amount = candidate.amount.min(amountLeft)) + val chosenAmount = routeParams.mpp.splittingStrategy match { case MultiPartParams.Randomize => // randomly choose the amount to be between 20% and 100% of the available capacity. val randomizedAmount = candidate.amount * ((20d + Random.nextInt(81)) / 100) - candidate.copy(amount = randomizedAmount.max(routeParams.mpp.minPartAmount).min(amount)) + if (randomizedAmount < routeParams.mpp.minPartAmount) { + routeParams.mpp.minPartAmount.min(amountLeft) + } else { + randomizedAmount.min(amountLeft) + } case MultiPartParams.MaxExpectedAmount => - val bestAmount = optimizeExpectedValue(current.path, candidate.amount, usedCapacity) - candidate.copy(amount = bestAmount.max(routeParams.mpp.minPartAmount).min(amount)) + val bestAmount = optimizeExpectedValue(current.path, candidate.amount, usedCapacity, routeParams.heuristics.usePastRelaysData, balances, now) + bestAmount.max(routeParams.mpp.minPartAmount).min(amountLeft) case MultiPartParams.FullCapacity => - candidate.copy(amount = candidate.amount.min(amount)) + routeFullCapacity.amount } - updateUsedCapacity(route, usedCapacity) - // NB: we re-enqueue the current path, it may still have capacity for a second HTLC. - split(amount - route.amount, paths.enqueue(current), usedCapacity, routeParams, route +: selectedRoutes) + updateUsedCapacity(routeFullCapacity, usedCapacity) + candidates = CandidateRoute(routeFullCapacity, chosenAmount) :: candidates + amountLeft = amountLeft - chosenAmount + paths.enqueue(current) } } + val totalChosen = candidates.map(_.chosenAmount).sum + val totalMaximum = candidates.map(_.routeWithMaximumAmount.amount).sum + if (totalMaximum < amount) { + Left(RouteNotFound) + } else { + val additionalFraction = if (totalMaximum > totalChosen) (amount - totalChosen).toLong.toDouble / (totalMaximum - totalChosen).toLong.toDouble else 0.0 + var routes: List[Route] = Nil + var amountLeft = amount + candidates.foreach { case CandidateRoute(route, chosenAmount) => + if (amountLeft > 0.msat) { + val additionalAmount = MilliSatoshi(((route.amount - chosenAmount).toLong * additionalFraction).ceil.toLong) + val amountToSend = (chosenAmount + additionalAmount).min(amountLeft) + routes = route.copy(amount = amountToSend) :: routes + amountLeft = amountLeft - amountToSend + } + } + Right(routes) + } } - private def optimizeExpectedValue(route: Seq[GraphEdge], capacity: MilliSatoshi, usedCapacity: mutable.Map[ShortChannelId, MilliSatoshi]): MilliSatoshi = { + private def optimizeExpectedValue(route: Seq[GraphEdge], capacity: MilliSatoshi, usedCapacity: mutable.Map[ShortChannelId, MilliSatoshi], usePastRelaysData: Boolean, balances: BalancesEstimates, now: TimestampSecond): MilliSatoshi = { // We search the maximum value of a polynomial between its two smallest roots (0 and the minimum channel capacity on the path). // We use binary search to find where the derivative changes sign. var low = 1L var high = capacity.toLong while (high - low > 1L) { - val mid = (high + low) / 2 - val d = route.drop(1).foldLeft(1.0 / mid) { case (x, edge) => - val availableCapacity = edge.capacity - usedCapacity.getOrElse(edge.desc.shortChannelId, 0 msat) - x - 1.0 / (availableCapacity.toLong - mid) + val x = (high + low) / 2 + val d = route.drop(1).foldLeft(1.0 / x.toDouble) { case (total, edge) => + // We compute the success probability `p` for this edge, and its derivative `dp`. + val (p, dp) = if (usePastRelaysData) { + balances.get(edge).canSendAndDerivative(MilliSatoshi(x), now) + } else { + val c = (edge.capacity - usedCapacity.getOrElse(edge.desc.shortChannelId, 0 msat)).toLong.toDouble + (1.0 - x.toDouble / c, -1.0 / c) + } + total + dp / p } if (d > 0.0) { - low = mid + low = x } else { - high = mid + high = x } } MilliSatoshi(high) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/router/Router.scala b/eclair-core/src/main/scala/fr/acinq/eclair/router/Router.scala index c748302aa0..28b8e95b43 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/router/Router.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/router/Router.scala @@ -38,7 +38,7 @@ import fr.acinq.eclair.payment.send.Recipient import fr.acinq.eclair.payment.{Bolt11Invoice, Invoice} import fr.acinq.eclair.remote.EclairInternalsSerializer.RemoteTypes import fr.acinq.eclair.router.Graph.GraphStructure.DirectedGraph -import fr.acinq.eclair.router.Graph.MessageWeightRatios +import fr.acinq.eclair.router.Graph.{HeuristicsConstants, MessageWeightRatios} import fr.acinq.eclair.router.Monitoring.Metrics import fr.acinq.eclair.wire.protocol._ @@ -354,7 +354,7 @@ object Router { case class PathFindingConf(randomize: Boolean, boundaries: SearchBoundaries, - heuristics: Graph.WeightRatios[Graph.PaymentPathWeight], + heuristics: HeuristicsConstants, mpp: MultiPartParams, experimentName: String, experimentPercentage: Int) { @@ -594,7 +594,7 @@ object Router { case class RouteParams(randomize: Boolean, boundaries: SearchBoundaries, - heuristics: Graph.WeightRatios[Graph.PaymentPathWeight], + heuristics: HeuristicsConstants, mpp: MultiPartParams, experimentName: String, includeLocalChannelCost: Boolean) { diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/router/RouteCalculationSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/router/RouteCalculationSpec.scala index 344e910151..9ffcb49e32 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/router/RouteCalculationSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/router/RouteCalculationSpec.scala @@ -26,6 +26,7 @@ import fr.acinq.eclair.router.Graph.GraphStructure.DirectedGraph.graphEdgeToHop import fr.acinq.eclair.router.Graph.GraphStructure.{DirectedGraph, GraphEdge} import fr.acinq.eclair.router.Graph.{HeuristicsConstants, PaymentPathWeight} import fr.acinq.eclair.router.RouteCalculation._ +import fr.acinq.eclair.router.Router.MultiPartParams.{FullCapacity, MaxExpectedAmount, Randomize} import fr.acinq.eclair.router.Router._ import fr.acinq.eclair.transactions.Transactions import fr.acinq.eclair.wire.protocol._ @@ -766,6 +767,7 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution { .modify(_.boundaries.maxFeeFlat).setTo(7 msat) .modify(_.boundaries.maxFeeProportional).setTo(0) .modify(_.randomize).setTo(true) + .modify(_.mpp.splittingStrategy).setTo(Randomize) val strictFee = strictFeeParams.getMaxFee(DEFAULT_AMOUNT_MSAT) assert(strictFee == 7.msat) @@ -914,7 +916,13 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution { checkRouteAmounts(routes, amount, 0 msat) } { - val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = routeParams.copy(randomize = true), currentBlockHeight = BlockHeight(400000)) + val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = routeParams.copy(randomize = true).modify(_.mpp.splittingStrategy).setTo(Randomize), currentBlockHeight = BlockHeight(400000)) + assert(routes.length >= 4, routes) + assert(routes.forall(_.hops.length == 1), routes) + checkRouteAmounts(routes, amount, 0 msat) + } + { + val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = routeParams.modify(_.mpp.splittingStrategy).setTo(MaxExpectedAmount), currentBlockHeight = BlockHeight(400000)) assert(routes.length >= 4, routes) assert(routes.forall(_.hops.length == 1), routes) checkRouteAmounts(routes, amount, 0 msat) @@ -977,7 +985,14 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution { checkRouteAmounts(routes, amount, 0 msat) } { - val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true), currentBlockHeight = BlockHeight(400000)) + 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)) + assert(routes.length >= 3, routes) + assert(routes.forall(_.hops.length == 1), routes) + checkIgnoredChannels(routes, 2L) + checkRouteAmounts(routes, amount, 0 msat) + } + { + val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS.modify(_.mpp.splittingStrategy).setTo(MaxExpectedAmount), currentBlockHeight = BlockHeight(400000)) assert(routes.length >= 3, routes) assert(routes.forall(_.hops.length == 1), routes) checkIgnoredChannels(routes, 2L) @@ -1087,7 +1102,11 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution { assert(result == Failure(RouteNotFound)) } { - val result = findMultiPartRoute(g, a, b, 40000 msat, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true), currentBlockHeight = BlockHeight(400000)) + 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)) + assert(result == Failure(RouteNotFound)) + } + { + val result = findMultiPartRoute(g, a, b, 40000 msat, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS.modify(_.mpp.splittingStrategy).setTo(MaxExpectedAmount), currentBlockHeight = BlockHeight(400000)) assert(result == Failure(RouteNotFound)) } } @@ -1116,7 +1135,11 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution { assert(result == Failure(RouteNotFound)) } { - val result = findMultiPartRoute(g, a, b, 10000 msat, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true), currentBlockHeight = BlockHeight(400000)) + 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)) + assert(result == Failure(RouteNotFound)) + } + { + val result = findMultiPartRoute(g, a, b, 10000 msat, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS.modify(_.mpp.splittingStrategy).setTo(MaxExpectedAmount), currentBlockHeight = BlockHeight(400000)) assert(result == Failure(RouteNotFound)) } } @@ -1152,7 +1175,11 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution { } { // Randomize routes. - val Success(routes) = findMultiPartRoute(g, a, e, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true), currentBlockHeight = BlockHeight(400000)) + val Success(routes) = findMultiPartRoute(g, a, e, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true).modify(_.mpp.splittingStrategy).setTo(Randomize), currentBlockHeight = BlockHeight(400000)) + checkRouteAmounts(routes, amount, maxFee) + } + { + val Success(routes) = findMultiPartRoute(g, a, e, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS.modify(_.mpp.splittingStrategy).setTo(MaxExpectedAmount), currentBlockHeight = BlockHeight(400000)) checkRouteAmounts(routes, amount, maxFee) } { @@ -1242,7 +1269,11 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution { } { // Randomize routes. - val Success(routes) = findMultiPartRoute(g, a, f, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true), currentBlockHeight = BlockHeight(400000)) + val Success(routes) = findMultiPartRoute(g, a, f, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true).modify(_.mpp.splittingStrategy).setTo(Randomize), currentBlockHeight = BlockHeight(400000)) + checkRouteAmounts(routes, amount, maxFee) + } + { + val Success(routes) = findMultiPartRoute(g, a, f, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS.modify(_.mpp.splittingStrategy).setTo(MaxExpectedAmount), currentBlockHeight = BlockHeight(400000)) checkRouteAmounts(routes, amount, maxFee) } { @@ -1300,7 +1331,7 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution { { val amount = 15_000_000 msat val maxFee = 50_000 msat // this fee is enough to go through the preferred route - val routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = false, mpp = MultiPartParams(50_000 msat, 5, DEFAULT_ROUTE_PARAMS.mpp.splittingStrategy)) + val routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = false, mpp = MultiPartParams(50_000 msat, 5, FullCapacity)) val Success(routes) = findMultiPartRoute(g, a, d, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000)) checkRouteAmounts(routes, amount, maxFee) assert(routes2Ids(routes) == Set(Seq(100L, 101L))) @@ -1308,14 +1339,14 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution { { val amount = 15_000_000 msat val maxFee = 10_000 msat // this fee is too low to go through the preferred route - val routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = false, mpp = MultiPartParams(50_000 msat, 5, DEFAULT_ROUTE_PARAMS.mpp.splittingStrategy)) + val routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = false, mpp = MultiPartParams(50_000 msat, 5, FullCapacity)) val failure = findMultiPartRoute(g, a, d, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000)) assert(failure == Failure(RouteNotFound)) } { val amount = 5_000_000 msat val maxFee = 10_000 msat // this fee is enough to go through the preferred route, but the cheaper ones can handle it - val routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = false, mpp = MultiPartParams(50_000 msat, 5, DEFAULT_ROUTE_PARAMS.mpp.splittingStrategy)) + val routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = false, mpp = MultiPartParams(50_000 msat, 5, FullCapacity)) val Success(routes) = findMultiPartRoute(g, a, d, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000)) assert(routes.length == 5) routes.foreach(route => { @@ -1429,7 +1460,12 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution { } { val (amount, maxFee) = (40000 msat, 100 msat) - val Success(routes) = findMultiPartRoute(g, d, f, amount, maxFee, routeParams = routeParams.copy(randomize = true), currentBlockHeight = BlockHeight(400000)) + val Success(routes) = findMultiPartRoute(g, d, f, amount, maxFee, routeParams = routeParams.copy(randomize = true).modify(_.mpp.splittingStrategy).setTo(Randomize), currentBlockHeight = BlockHeight(400000)) + checkRouteAmounts(routes, amount, maxFee) + } + { + val (amount, maxFee) = (40000 msat, 100 msat) + val Success(routes) = findMultiPartRoute(g, d, f, amount, maxFee, routeParams = routeParams.modify(_.mpp.splittingStrategy).setTo(MaxExpectedAmount), currentBlockHeight = BlockHeight(400000)) checkRouteAmounts(routes, amount, maxFee) } { @@ -1528,7 +1564,7 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution { 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) )), 1 day) - findMultiPartRoute(g, d, f, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true), currentBlockHeight = BlockHeight(400000)) match { + findMultiPartRoute(g, d, f, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true).modify(_.mpp.splittingStrategy).setTo(Randomize), currentBlockHeight = BlockHeight(400000)) match { case Success(routes) => checkRouteAmounts(routes, amount, maxFee) case Failure(ex) => assert(ex == RouteNotFound) } @@ -1949,7 +1985,7 @@ object RouteCalculationSpec { randomize = false, boundaries = SearchBoundaries(21000 msat, 0.03, 6, CltvExpiryDelta(2016)), NO_WEIGHT_RATIOS, - MultiPartParams(1000 msat, 10, MultiPartParams.FullCapacity), + MultiPartParams(1000 msat, 10, FullCapacity), experimentName = "my-test-experiment", experimentPercentage = 100).getDefaultRouteParams diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/router/RouterSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/router/RouterSpec.scala index cd8b6e85aa..e62ca9ae36 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/router/RouterSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/router/RouterSpec.scala @@ -558,7 +558,7 @@ class RouterSpec extends BaseRouterSpec { router ! RouteRequest(sender.ref, a, recipient, DEFAULT_ROUTE_PARAMS, allowMultiPart = true) val routes = sender.expectMessageType[RouteResponse].routes assert(routes.length == 2) - assert(routes.flatMap(_.finalHop_opt) == recipient.blindedHops) + assert(routes.flatMap(_.finalHop_opt).toSet == recipient.blindedHops.toSet) assert(routes.map(route => route2NodeIds(route)).toSet == Set(Seq(a, b), Seq(a, b, c))) assert(routes.map(route => route.blindedFee + route.channelFee(false)).toSet == Set(510 msat, 800 msat)) } @@ -580,45 +580,6 @@ class RouterSpec extends BaseRouterSpec { router ! RouteRequest(sender.ref, a, recipient, DEFAULT_ROUTE_PARAMS, allowMultiPart = true, ignore = ignored) sender.expectMessage(PaymentRouteNotFound(RouteNotFound)) } - { - // One blinded route is pending, we use the other one: - val (_, recipient) = blindedRoutesFromPaths(600_000 msat, DEFAULT_EXPIRY, hopsToRecipient, DEFAULT_EXPIRY) - router ! RouteRequest(sender.ref, a, recipient, DEFAULT_ROUTE_PARAMS, allowMultiPart = true) - val routes1 = sender.expectMessageType[RouteResponse].routes - assert(routes1.length == 2) - router ! RouteRequest(sender.ref, a, recipient, DEFAULT_ROUTE_PARAMS, allowMultiPart = true, pendingPayments = Seq(routes1.head)) - val routes2 = sender.expectMessageType[RouteResponse].routes - assert(routes2 == routes1.tail) - } - { - // One blinded route is pending, we send two htlcs to the other one: - val (_, recipient) = blindedRoutesFromPaths(600_000 msat, DEFAULT_EXPIRY, hopsToRecipient, DEFAULT_EXPIRY) - router ! RouteRequest(sender.ref, a, recipient, DEFAULT_ROUTE_PARAMS, allowMultiPart = true) - val routes1 = sender.expectMessageType[RouteResponse].routes - assert(routes1.length == 2) - router ! RouteRequest(sender.ref, a, recipient, DEFAULT_ROUTE_PARAMS, allowMultiPart = true, pendingPayments = Seq(routes1.head)) - val routes2 = sender.expectMessageType[RouteResponse].routes - assert(routes2 == routes1.tail) - router ! RouteRequest(sender.ref, a, recipient, DEFAULT_ROUTE_PARAMS, allowMultiPart = true, pendingPayments = Seq(routes1.head, routes2.head.copy(amount = routes2.head.amount - 25_000.msat))) - val routes3 = sender.expectMessageType[RouteResponse].routes - assert(routes3.length == 1) - assert(routes3.head.amount == 25_000.msat) - } - { - // One blinded route is pending, we cannot use the other one because of the fee budget: - val (_, recipient) = blindedRoutesFromPaths(600_000 msat, DEFAULT_EXPIRY, hopsToRecipient, DEFAULT_EXPIRY) - val routeParams1 = DEFAULT_ROUTE_PARAMS.copy(boundaries = SearchBoundaries(5000 msat, 0.0, 6, CltvExpiryDelta(1008))) - router ! RouteRequest(sender.ref, a, recipient, routeParams1, allowMultiPart = true) - val routes1 = sender.expectMessageType[RouteResponse].routes - assert(routes1.length == 2) - assert(routes1.head.blindedFee + routes1.head.channelFee(false) == 800.msat) - val routeParams2 = DEFAULT_ROUTE_PARAMS.copy(boundaries = SearchBoundaries(1000 msat, 0.0, 6, CltvExpiryDelta(1008))) - router ! RouteRequest(sender.ref, a, recipient, routeParams2, allowMultiPart = true, pendingPayments = Seq(routes1.head)) - sender.expectMessage(PaymentRouteNotFound(RouteNotFound)) - val routeParams3 = DEFAULT_ROUTE_PARAMS.copy(boundaries = SearchBoundaries(1500 msat, 0.0, 6, CltvExpiryDelta(1008))) - router ! RouteRequest(sender.ref, a, recipient, routeParams3, allowMultiPart = true, pendingPayments = Seq(routes1.head)) - assert(sender.expectMessageType[RouteResponse].routes.length == 1) - } } test("route not found (channel disabled)") { fixture => From c654df7b5c798145771e24c6f6c0d5fe35310a4d Mon Sep 17 00:00:00 2001 From: Thomas HUET Date: Fri, 10 Oct 2025 16:11:40 +0200 Subject: [PATCH 3/6] t-bast review --- docs/release-notes/eclair-vnext.md | 5 ++++- .../eclair/remote/EclairInternalsSerializer.scala | 14 ++++---------- .../fr/acinq/eclair/router/RouteCalculation.scala | 1 + 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/docs/release-notes/eclair-vnext.md b/docs/release-notes/eclair-vnext.md index f1bdc9f01b..c590cf5a69 100644 --- a/docs/release-notes/eclair-vnext.md +++ b/docs/release-notes/eclair-vnext.md @@ -8,7 +8,10 @@ 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`). These splitting strategies are now specified using `mpp.splitting-strategy = "full-capacity"` or `mpp.splitting-strategy = "randomize"`. -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). +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). + +Eclair's path-finding algorithm can be customized by modifying the `eclair.router.path-finding.experiments.*` sections of your `eclair.conf`. +The new `mpp.splitting-strategy` goes in these sections, or in `eclair.router.path-finding.default` from which they inherit. ### Remove support for legacy channel codecs diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/remote/EclairInternalsSerializer.scala b/eclair-core/src/main/scala/fr/acinq/eclair/remote/EclairInternalsSerializer.scala index e3cbe16920..305b1410ee 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/remote/EclairInternalsSerializer.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/remote/EclairInternalsSerializer.scala @@ -71,16 +71,10 @@ object EclairInternalsSerializer { val multiPartParamsCodec: Codec[MultiPartParams] = ( ("minPartAmount" | millisatoshi) :: ("maxParts" | int32) :: - ("splittingStrategy" | int8.narrow[MultiPartParams.SplittingStrategy]({ - case 0 => Attempt.successful(MultiPartParams.FullCapacity) - case 1 => Attempt.successful(MultiPartParams.Randomize) - case 2 => Attempt.successful(MultiPartParams.MaxExpectedAmount) - case n => Attempt.failure(Err(s"Invalid value $n")) - }, { - case MultiPartParams.FullCapacity => 0 - case MultiPartParams.Randomize => 1 - case MultiPartParams.MaxExpectedAmount => 2 - }))).as[MultiPartParams] + ("splittingStrategy" | discriminated[MultiPartParams.SplittingStrategy].by(uint8) + .typecase(0, provide(MultiPartParams.FullCapacity)) + .typecase(1, provide(MultiPartParams.Randomize)) + .typecase(2, provide(MultiPartParams.MaxExpectedAmount)))).as[MultiPartParams] val pathFindingConfCodec: Codec[PathFindingConf] = ( ("randomize" | bool(8)) :: diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/router/RouteCalculation.scala b/eclair-core/src/main/scala/fr/acinq/eclair/router/RouteCalculation.scala index 1481a37330..67641dfbb1 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/router/RouteCalculation.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/router/RouteCalculation.scala @@ -506,6 +506,7 @@ object RouteCalculation { val (p, dp) = if (usePastRelaysData) { balances.get(edge).canSendAndDerivative(MilliSatoshi(x), now) } else { + // If not using past relays data, we assume that balances are uniformly distributed between 0 and the full capacity of the channel. val c = (edge.capacity - usedCapacity.getOrElse(edge.desc.shortChannelId, 0 msat)).toLong.toDouble (1.0 - x.toDouble / c, -1.0 / c) } From 8939fd9fb1c82b4e82be884f3e85058ef557d474 Mon Sep 17 00:00:00 2001 From: Thomas HUET Date: Mon, 13 Oct 2025 12:02:25 +0200 Subject: [PATCH 4/6] CandidateRoute --- .../eclair/router/RouteCalculation.scala | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/router/RouteCalculation.scala b/eclair-core/src/main/scala/fr/acinq/eclair/router/RouteCalculation.scala index 67641dfbb1..de9d79700f 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/router/RouteCalculation.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/router/RouteCalculation.scala @@ -443,7 +443,7 @@ object RouteCalculation { } } - private case class CandidateRoute(routeWithMaximumAmount: Route, chosenAmount: MilliSatoshi) + private case class CandidateRoute(route: Route, maxAmount: MilliSatoshi) 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]] = { var amountLeft = amount @@ -452,7 +452,7 @@ object RouteCalculation { val current = paths.dequeue() val candidate = computeRouteMaxAmount(current.path, usedCapacity) if (candidate.amount >= routeParams.mpp.minPartAmount.min(amountLeft)) { - val routeFullCapacity = candidate.copy(amount = candidate.amount.min(amountLeft)) + val maxAmount = candidate.amount.min(amountLeft) val chosenAmount = routeParams.mpp.splittingStrategy match { case MultiPartParams.Randomize => // randomly choose the amount to be between 20% and 100% of the available capacity. @@ -466,26 +466,27 @@ object RouteCalculation { val bestAmount = optimizeExpectedValue(current.path, candidate.amount, usedCapacity, routeParams.heuristics.usePastRelaysData, balances, now) bestAmount.max(routeParams.mpp.minPartAmount).min(amountLeft) case MultiPartParams.FullCapacity => - routeFullCapacity.amount + maxAmount } - updateUsedCapacity(routeFullCapacity, usedCapacity) - candidates = CandidateRoute(routeFullCapacity, chosenAmount) :: candidates + val route = candidate.copy(amount = chosenAmount) + updateUsedCapacity(route.copy(amount = maxAmount), usedCapacity) + candidates = CandidateRoute(route, maxAmount) :: candidates amountLeft = amountLeft - chosenAmount paths.enqueue(current) } } - val totalChosen = candidates.map(_.chosenAmount).sum - val totalMaximum = candidates.map(_.routeWithMaximumAmount.amount).sum + val totalChosen = candidates.map(_.route.amount).sum + val totalMaximum = candidates.map(_.maxAmount).sum if (totalMaximum < amount) { Left(RouteNotFound) } else { val additionalFraction = if (totalMaximum > totalChosen) (amount - totalChosen).toLong.toDouble / (totalMaximum - totalChosen).toLong.toDouble else 0.0 var routes: List[Route] = Nil var amountLeft = amount - candidates.foreach { case CandidateRoute(route, chosenAmount) => + candidates.foreach { case CandidateRoute(route, maxAmount) => if (amountLeft > 0.msat) { - val additionalAmount = MilliSatoshi(((route.amount - chosenAmount).toLong * additionalFraction).ceil.toLong) - val amountToSend = (chosenAmount + additionalAmount).min(amountLeft) + val additionalAmount = MilliSatoshi(((maxAmount - route.amount).toLong * additionalFraction).ceil.toLong) + val amountToSend = (route.amount + additionalAmount).min(amountLeft) routes = route.copy(amount = amountToSend) :: routes amountLeft = amountLeft - amountToSend } From 7241ee898c37c5b8f2ea3c6fe83f9bc9ecca5cca Mon Sep 17 00:00:00 2001 From: Thomas HUET Date: Mon, 13 Oct 2025 14:59:53 +0200 Subject: [PATCH 5/6] do not send more than htlcMaximum for remote channels it's still allowed for local channels (in several HTLCs) --- .../scala/fr/acinq/eclair/router/Graph.scala | 7 --- .../eclair/router/RouteCalculation.scala | 21 +++++++-- .../fr/acinq/eclair/EclairImplSpec.scala | 10 ++--- .../eclair/router/RouteCalculationSpec.scala | 43 +++---------------- .../fr/acinq/eclair/router/RouterSpec.scala | 39 +++++++++++++++++ 5 files changed, 68 insertions(+), 52 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/router/Graph.scala b/eclair-core/src/main/scala/fr/acinq/eclair/router/Graph.scala index 822e163123..13d98e2c8a 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/router/Graph.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/router/Graph.scala @@ -501,13 +501,6 @@ object Graph { * @param balance_opt (optional) available balance that can be sent through this edge */ case class GraphEdge private(desc: ChannelDesc, params: HopRelayParams, capacity: Satoshi, balance_opt: Option[MilliSatoshi]) { - - def maxHtlcAmount(reservedCapacity: MilliSatoshi): MilliSatoshi = Seq( - balance_opt.map(balance => balance - reservedCapacity), - params.htlcMaximum_opt, - Some(capacity.toMilliSatoshi - reservedCapacity) - ).flatten.min.max(0 msat) - def fee(amount: MilliSatoshi): MilliSatoshi = params.fee(amount) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/router/RouteCalculation.scala b/eclair-core/src/main/scala/fr/acinq/eclair/router/RouteCalculation.scala index de9d79700f..5a0c54cabd 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/router/RouteCalculation.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/router/RouteCalculation.scala @@ -448,6 +448,7 @@ object RouteCalculation { 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]] = { var amountLeft = amount var candidates: List[CandidateRoute] = Nil + // We build some candidate route but may adjust the amounts later if the don't cover the full amount we need to send. while(paths.nonEmpty && amountLeft > 0.msat) { val current = paths.dequeue() val candidate = computeRouteMaxAmount(current.path, usedCapacity) @@ -469,12 +470,13 @@ object RouteCalculation { maxAmount } val route = candidate.copy(amount = chosenAmount) - updateUsedCapacity(route.copy(amount = maxAmount), usedCapacity) + updateUsedCapacity(route.copy(amount = maxAmount), usedCapacity) // We use `maxAmount` because we may send up to `maxAmount`. candidates = CandidateRoute(route, maxAmount) :: candidates amountLeft = amountLeft - chosenAmount paths.enqueue(current) } } + // We adjust the amounts to send through each route. val totalChosen = candidates.map(_.route.amount).sum val totalMaximum = candidates.map(_.maxAmount).sum if (totalMaximum < amount) { @@ -524,16 +526,20 @@ object RouteCalculation { /** Compute the maximum amount that we can send through the given route. */ private def computeRouteMaxAmount(route: Seq[GraphEdge], usedCapacity: mutable.Map[ShortChannelId, MilliSatoshi]): Route = { - val firstHopMaxAmount = route.head.maxHtlcAmount(usedCapacity.getOrElse(route.head.desc.shortChannelId, 0 msat)) + val firstHopMaxAmount = maxEdgeAmount(route.head, usedCapacity.getOrElse(route.head.desc.shortChannelId, 0 msat)) val amount = route.drop(1).foldLeft(firstHopMaxAmount) { case (amount, edge) => // We compute fees going forward instead of backwards. That means we will slightly overestimate the fees of some // edges, but we will always stay inside the capacity bounds we computed. val amountMinusFees = amount - edge.fee(amount) - val edgeMaxAmount = edge.maxHtlcAmount(usedCapacity.getOrElse(edge.desc.shortChannelId, 0 msat)) + val edgeMaxAmount = maxEdgeAmount(edge, usedCapacity.getOrElse(edge.desc.shortChannelId, 0 msat)) amountMinusFees.min(edgeMaxAmount) } Route(amount.max(0 msat), route.map(graphEdgeToHop), None) } + private def maxEdgeAmount(edge: GraphEdge, usedCapacity: MilliSatoshi): MilliSatoshi = { + val maxBalance = edge.balance_opt.getOrElse(edge.params.htlcMaximum_opt.getOrElse(edge.capacity.toMilliSatoshi)) + Seq(Some(maxBalance - usedCapacity), edge.params.htlcMaximum_opt).flatten.min.max(0 msat) + } /** Initialize known used capacity based on pending HTLCs. */ private def initializeUsedCapacity(pendingHtlcs: Seq[Route]): mutable.Map[ShortChannelId, MilliSatoshi] = { @@ -546,7 +552,14 @@ object RouteCalculation { /** Update used capacity by taking into account an HTLC sent to the given route. */ private def updateUsedCapacity(route: Route, usedCapacity: mutable.Map[ShortChannelId, MilliSatoshi]): Unit = { - route.hops.foldRight(route.amount) { case (hop, amount) => + val finalHopAmount = route.finalHop_opt.map(hop => { + hop match { + case BlindedHop(dummyId, _) => usedCapacity.updateWith(dummyId)(previous => Some(route.amount + previous.getOrElse(0 msat))) + case _: NodeHop => () + } + route.amount + hop.fee(route.amount) + }).getOrElse(route.amount) + route.hops.foldRight(finalHopAmount) { case (hop, amount) => usedCapacity.updateWith(hop.shortChannelId)(previous => Some(amount + previous.getOrElse(0 msat))) amount + hop.fee(amount) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala index 0febbc57b0..6623043044 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala @@ -231,11 +231,11 @@ class EclairImplSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with I assert(Announcements.isNode1(b, c)) val channels = SortedMap(Seq( - (ann_ab, makeUpdateShort(ShortChannelId(1L), a, b, feeBase = 0 msat, 0, minHtlc = 0 msat, maxHtlc = None, cltvDelta = CltvExpiryDelta(13))), - (ann_ae, makeUpdateShort(ShortChannelId(4L), a, e, feeBase = 0 msat, 0, minHtlc = 0 msat, maxHtlc = None, cltvDelta = CltvExpiryDelta(12))), - (ann_bc, makeUpdateShort(ShortChannelId(2L), b, c, feeBase = 1 msat, 0, minHtlc = 0 msat, maxHtlc = None, cltvDelta = CltvExpiryDelta(500))), - (ann_cd, makeUpdateShort(ShortChannelId(3L), c, d, feeBase = 1 msat, 0, minHtlc = 0 msat, maxHtlc = None, cltvDelta = CltvExpiryDelta(500))), - (ann_ec, makeUpdateShort(ShortChannelId(7L), e, c, feeBase = 2 msat, 0, minHtlc = 0 msat, maxHtlc = None, cltvDelta = CltvExpiryDelta(12))) + (ann_ab, makeUpdateShort(ShortChannelId(1L), a, b, feeBase = 0 msat, 0, minHtlc = 0 msat, maxHtlc = 100000 msat, cltvDelta = CltvExpiryDelta(13))), + (ann_ae, makeUpdateShort(ShortChannelId(4L), a, e, feeBase = 0 msat, 0, minHtlc = 0 msat, maxHtlc = 100000 msat, cltvDelta = CltvExpiryDelta(12))), + (ann_bc, makeUpdateShort(ShortChannelId(2L), b, c, feeBase = 1 msat, 0, minHtlc = 0 msat, maxHtlc = 100000 msat, cltvDelta = CltvExpiryDelta(500))), + (ann_cd, makeUpdateShort(ShortChannelId(3L), c, d, feeBase = 1 msat, 0, minHtlc = 0 msat, maxHtlc = 100000 msat, cltvDelta = CltvExpiryDelta(500))), + (ann_ec, makeUpdateShort(ShortChannelId(7L), e, c, feeBase = 2 msat, 0, minHtlc = 0 msat, maxHtlc = 100000 msat, cltvDelta = CltvExpiryDelta(12))) ).map { case (ann, update) => update.shortChannelId -> PublicChannel(ann, TxId(ByteVector32.Zeroes), 100 sat, Some(update.copy(channelFlags = ChannelUpdate.ChannelFlags.DUMMY)), None, None) }: _*) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/router/RouteCalculationSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/router/RouteCalculationSpec.scala index 9ffcb49e32..7e716dbf51 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/router/RouteCalculationSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/router/RouteCalculationSpec.scala @@ -1168,7 +1168,7 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution { } { // Update A - B with unknown balance, capacity should be used instead. - val g1 = g.addEdge(edge_ab.copy(capacity = 15 sat, balance_opt = None)) + val g1 = g.addEdge(makeEdge(1L, a, b, 50 msat, 100, minHtlc = 1 msat, capacity = 15 sat, balance_opt = None)) val Success(routes) = findMultiPartRoute(g1, a, e, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000)) checkRouteAmounts(routes, amount, maxFee) assert(routes2Ids(routes) == Set(Seq(1L, 2L, 3L), Seq(4L, 6L), Seq(5L, 6L))) @@ -1190,7 +1190,7 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution { } { // Update capacity A - B to be too low. - val g1 = g.addEdge(edge_ab.copy(capacity = 5 sat, balance_opt = None)) + val g1 = g.addEdge(makeEdge(1L, a, b, 50 msat, 100, minHtlc = 1 msat, capacity = 5 sat, balance_opt = None)) val failure = findMultiPartRoute(g1, a, e, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000)) assert(failure == Failure(RouteNotFound)) } @@ -1278,7 +1278,7 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution { } { // Update A - B with unknown balance, capacity should be used instead. - val g1 = g.addEdge(edge_ab.copy(capacity = 500 sat, balance_opt = None)) + val g1 = g.addEdge(makeEdge(1L, a, b, 50 msat, 100, minHtlc = 1 msat, capacity = 500 sat, balance_opt = None)) val Success(routes) = findMultiPartRoute(g1, a, f, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000)) checkRouteAmounts(routes, amount, maxFee) assert(routes2Ids(routes) == Set(Seq(1L, 2L, 3L, 5L), Seq(1L, 4L, 5L), Seq(1L, 6L, 7L))) @@ -1291,7 +1291,7 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution { } { // Update capacity A - B to be too low to cover fees. - val g1 = g.addEdge(edge_ab.copy(capacity = 400 sat, balance_opt = None)) + val g1 = g.addEdge(makeEdge(1L, a, b, 50 msat, 100, minHtlc = 1 msat, capacity = 400 sat, balance_opt = None)) val failure = findMultiPartRoute(g1, a, f, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000)) assert(failure == Failure(RouteNotFound)) } @@ -1388,35 +1388,6 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution { assert(routes2Ids(routes) == Set(Seq(8L), Seq(9L, 10L))) } - test("calculate multipart route to remote node (restricted htlc_minimum_msat and htlc_maximum_msat)") { - // +----- B -----+ - // | | - // A----- C ---- E - // | | - // +----- D -----+ - val (amount, maxFee) = (15000 msat, 5 msat) - val g = GraphWithBalanceEstimates(DirectedGraph(List( - // The A -> B -> E path is impossible because the A -> B balance is lower than the B -> E htlc_minimum_msat. - makeEdge(1L, a, b, 1 msat, 0, minHtlc = 500 msat, balance_opt = Some(7000 msat)), - makeEdge(2L, b, e, 1 msat, 0, minHtlc = 10000 msat, capacity = 50 sat), - makeEdge(3L, a, c, 1 msat, 0, minHtlc = 500 msat, balance_opt = Some(10000 msat)), - makeEdge(4L, c, e, 1 msat, 0, minHtlc = 500 msat, maxHtlc = Some(4000 msat), capacity = 50 sat), - makeEdge(5L, a, d, 1 msat, 0, minHtlc = 500 msat, balance_opt = Some(10000 msat)), - makeEdge(6L, d, e, 1 msat, 0, minHtlc = 500 msat, maxHtlc = Some(4000 msat), capacity = 50 sat), - )), 1 day) - - val Success(routes) = findMultiPartRoute(g, a, e, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000)) - checkRouteAmounts(routes, amount, maxFee) - assert(routes.length >= 4, routes) - assert(routes.forall(_.amount <= 4000.msat), routes) - assert(routes.forall(_.amount >= 500.msat), routes) - checkIgnoredChannels(routes, 1L, 2L) - - val maxFeeTooLow = 3 msat - val failure = findMultiPartRoute(g, a, e, amount, maxFeeTooLow, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000)) - assert(failure == Failure(RouteNotFound)) - } - test("calculate multipart route to remote node (complex graph)") { // +---+ +---+ +---+ // | A |-----+ +--->| B |--->| C | @@ -2006,11 +1977,11 @@ object RouteCalculationSpec { cltvDelta: CltvExpiryDelta = CltvExpiryDelta(0), capacity: Satoshi = DEFAULT_CAPACITY, balance_opt: Option[MilliSatoshi] = None): GraphEdge = { - val update = makeUpdateShort(ShortChannelId(shortChannelId), nodeId1, nodeId2, feeBase, feeProportionalMillionth, minHtlc, maxHtlc, cltvDelta) + val update = makeUpdateShort(ShortChannelId(shortChannelId), nodeId1, nodeId2, feeBase, feeProportionalMillionth, minHtlc, maxHtlc.getOrElse(capacity.toMilliSatoshi), cltvDelta) GraphEdge(ChannelDesc(RealShortChannelId(shortChannelId), nodeId1, nodeId2), HopRelayParams.FromAnnouncement(update), capacity, balance_opt) } - def makeUpdateShort(shortChannelId: ShortChannelId, nodeId1: PublicKey, nodeId2: PublicKey, feeBase: MilliSatoshi, feeProportionalMillionth: Int, minHtlc: MilliSatoshi = DEFAULT_AMOUNT_MSAT, maxHtlc: Option[MilliSatoshi] = None, cltvDelta: CltvExpiryDelta = CltvExpiryDelta(0), timestamp: TimestampSecond = 0 unixsec): ChannelUpdate = + def makeUpdateShort(shortChannelId: ShortChannelId, nodeId1: PublicKey, nodeId2: PublicKey, feeBase: MilliSatoshi, feeProportionalMillionth: Int, minHtlc: MilliSatoshi = DEFAULT_AMOUNT_MSAT, maxHtlc: MilliSatoshi = 500_000_000 msat, cltvDelta: CltvExpiryDelta = CltvExpiryDelta(0), timestamp: TimestampSecond = 0 unixsec): ChannelUpdate = ChannelUpdate( signature = DUMMY_SIG, chainHash = Block.RegtestGenesisBlock.hash, @@ -2022,7 +1993,7 @@ object RouteCalculationSpec { htlcMinimumMsat = minHtlc, feeBaseMsat = feeBase, feeProportionalMillionths = feeProportionalMillionth, - htlcMaximumMsat = maxHtlc.getOrElse(500_000_000 msat) + htlcMaximumMsat = maxHtlc ) def hops2Ids(hops: Seq[ChannelHop]): Seq[Long] = hops.map(hop => hop.shortChannelId.toLong) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/router/RouterSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/router/RouterSpec.scala index e62ca9ae36..fc40090a91 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/router/RouterSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/router/RouterSpec.scala @@ -580,6 +580,45 @@ class RouterSpec extends BaseRouterSpec { router ! RouteRequest(sender.ref, a, recipient, DEFAULT_ROUTE_PARAMS, allowMultiPart = true, ignore = ignored) sender.expectMessage(PaymentRouteNotFound(RouteNotFound)) } + { + // One blinded route is pending, we use the other one: + val (_, recipient) = blindedRoutesFromPaths(600_000 msat, DEFAULT_EXPIRY, hopsToRecipient, DEFAULT_EXPIRY) + router ! RouteRequest(sender.ref, a, recipient, DEFAULT_ROUTE_PARAMS, allowMultiPart = true) + val routes1 = sender.expectMessageType[RouteResponse].routes + assert(routes1.length == 2) + router ! RouteRequest(sender.ref, a, recipient, DEFAULT_ROUTE_PARAMS, allowMultiPart = true, pendingPayments = Seq(routes1.head)) + val routes2 = sender.expectMessageType[RouteResponse].routes + assert(routes2 == routes1.tail) + } + { + // One blinded route is pending, we send two htlcs to the other one: + val (_, recipient) = blindedRoutesFromPaths(600_000 msat, DEFAULT_EXPIRY, hopsToRecipient, DEFAULT_EXPIRY) + router ! RouteRequest(sender.ref, a, recipient, DEFAULT_ROUTE_PARAMS, allowMultiPart = true) + val routes1 = sender.expectMessageType[RouteResponse].routes + assert(routes1.length == 2) + router ! RouteRequest(sender.ref, a, recipient, DEFAULT_ROUTE_PARAMS, allowMultiPart = true, pendingPayments = Seq(routes1.head)) + val routes2 = sender.expectMessageType[RouteResponse].routes + assert(routes2 == routes1.tail) + router ! RouteRequest(sender.ref, a, recipient, DEFAULT_ROUTE_PARAMS, allowMultiPart = true, pendingPayments = Seq(routes1.head, routes2.head.copy(amount = routes2.head.amount - 25_000.msat))) + val routes3 = sender.expectMessageType[RouteResponse].routes + assert(routes3.length == 1) + assert(routes3.head.amount == 25_000.msat) + } + { + // One blinded route is pending, we cannot use the other one because of the fee budget: + val (_, recipient) = blindedRoutesFromPaths(600_000 msat, DEFAULT_EXPIRY, hopsToRecipient, DEFAULT_EXPIRY) + val routeParams1 = DEFAULT_ROUTE_PARAMS.copy(boundaries = SearchBoundaries(5000 msat, 0.0, 6, CltvExpiryDelta(1008))) + router ! RouteRequest(sender.ref, a, recipient, routeParams1, allowMultiPart = true) + val routes1 = sender.expectMessageType[RouteResponse].routes + assert(routes1.length == 2) + assert(routes1.map(r => r.blindedFee + r.channelFee(false)) == Seq(510 msat, 800 msat)) + val routeParams2 = DEFAULT_ROUTE_PARAMS.copy(boundaries = SearchBoundaries(1000 msat, 0.0, 6, CltvExpiryDelta(1008))) + router ! RouteRequest(sender.ref, a, recipient, routeParams2, allowMultiPart = true, pendingPayments = Seq(routes1.head)) + sender.expectMessage(PaymentRouteNotFound(RouteNotFound)) + val routeParams3 = DEFAULT_ROUTE_PARAMS.copy(boundaries = SearchBoundaries(1500 msat, 0.0, 6, CltvExpiryDelta(1008))) + router ! RouteRequest(sender.ref, a, recipient, routeParams3, allowMultiPart = true, pendingPayments = Seq(routes1.head)) + assert(sender.expectMessageType[RouteResponse].routes.length == 1) + } } test("route not found (channel disabled)") { fixture => From 949985453a592e05f042d2613904a073ce1002ec Mon Sep 17 00:00:00 2001 From: Thomas HUET Date: Tue, 14 Oct 2025 11:32:18 +0200 Subject: [PATCH 6/6] nits --- .../eclair/router/RouteCalculation.scala | 26 +++++++++---------- .../fr/acinq/eclair/router/RouterSpec.scala | 13 +++++----- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/router/RouteCalculation.scala b/eclair-core/src/main/scala/fr/acinq/eclair/router/RouteCalculation.scala index 5a0c54cabd..cba5f42684 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/router/RouteCalculation.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/router/RouteCalculation.scala @@ -453,36 +453,35 @@ object RouteCalculation { val current = paths.dequeue() val candidate = computeRouteMaxAmount(current.path, usedCapacity) if (candidate.amount >= routeParams.mpp.minPartAmount.min(amountLeft)) { - val maxAmount = candidate.amount.min(amountLeft) val chosenAmount = routeParams.mpp.splittingStrategy match { case MultiPartParams.Randomize => // randomly choose the amount to be between 20% and 100% of the available capacity. val randomizedAmount = candidate.amount * ((20d + Random.nextInt(81)) / 100) - if (randomizedAmount < routeParams.mpp.minPartAmount) { - routeParams.mpp.minPartAmount.min(amountLeft) - } else { - randomizedAmount.min(amountLeft) - } + randomizedAmount.max(routeParams.mpp.minPartAmount).min(amountLeft) case MultiPartParams.MaxExpectedAmount => val bestAmount = optimizeExpectedValue(current.path, candidate.amount, usedCapacity, routeParams.heuristics.usePastRelaysData, balances, now) bestAmount.max(routeParams.mpp.minPartAmount).min(amountLeft) case MultiPartParams.FullCapacity => - maxAmount + candidate.amount.min(amountLeft) } - val route = candidate.copy(amount = chosenAmount) - updateUsedCapacity(route.copy(amount = maxAmount), usedCapacity) // We use `maxAmount` because we may send up to `maxAmount`. - candidates = CandidateRoute(route, maxAmount) :: candidates + // We update the route with our chosen amount, which is always smaller than the maximum amount. + val chosenRoute = CandidateRoute(candidate.copy(amount = chosenAmount), candidate.amount) + // 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. + updateUsedCapacity(candidate, usedCapacity) + candidates = chosenRoute :: candidates amountLeft = amountLeft - chosenAmount paths.enqueue(current) } } // We adjust the amounts to send through each route. - val totalChosen = candidates.map(_.route.amount).sum val totalMaximum = candidates.map(_.maxAmount).sum - if (totalMaximum < amount) { + if (amountLeft == 0.msat) { + Right(candidates.map(_.route)) + } else if (totalMaximum < amount) { Left(RouteNotFound) } else { - val additionalFraction = if (totalMaximum > totalChosen) (amount - totalChosen).toLong.toDouble / (totalMaximum - totalChosen).toLong.toDouble else 0.0 + val totalChosen = candidates.map(_.route.amount).sum + val additionalFraction = (amount - totalChosen).toLong.toDouble / (totalMaximum - totalChosen).toLong.toDouble var routes: List[Route] = Nil var amountLeft = amount candidates.foreach { case CandidateRoute(route, maxAmount) => @@ -536,6 +535,7 @@ object RouteCalculation { } Route(amount.max(0 msat), route.map(graphEdgeToHop), None) } + private def maxEdgeAmount(edge: GraphEdge, usedCapacity: MilliSatoshi): MilliSatoshi = { val maxBalance = edge.balance_opt.getOrElse(edge.params.htlcMaximum_opt.getOrElse(edge.capacity.toMilliSatoshi)) Seq(Some(maxBalance - usedCapacity), edge.params.htlcMaximum_opt).flatten.min.max(0 msat) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/router/RouterSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/router/RouterSpec.scala index fc40090a91..d4d278029e 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/router/RouterSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/router/RouterSpec.scala @@ -586,9 +586,9 @@ class RouterSpec extends BaseRouterSpec { router ! RouteRequest(sender.ref, a, recipient, DEFAULT_ROUTE_PARAMS, allowMultiPart = true) val routes1 = sender.expectMessageType[RouteResponse].routes assert(routes1.length == 2) - router ! RouteRequest(sender.ref, a, recipient, DEFAULT_ROUTE_PARAMS, allowMultiPart = true, pendingPayments = Seq(routes1.head)) + router ! RouteRequest(sender.ref, a, recipient, DEFAULT_ROUTE_PARAMS, allowMultiPart = true, pendingPayments = routes1.drop(1)) val routes2 = sender.expectMessageType[RouteResponse].routes - assert(routes2 == routes1.tail) + assert(routes2 == routes1.take(1)) } { // One blinded route is pending, we send two htlcs to the other one: @@ -596,10 +596,11 @@ class RouterSpec extends BaseRouterSpec { router ! RouteRequest(sender.ref, a, recipient, DEFAULT_ROUTE_PARAMS, allowMultiPart = true) val routes1 = sender.expectMessageType[RouteResponse].routes assert(routes1.length == 2) - router ! RouteRequest(sender.ref, a, recipient, DEFAULT_ROUTE_PARAMS, allowMultiPart = true, pendingPayments = Seq(routes1.head)) + router ! RouteRequest(sender.ref, a, recipient, DEFAULT_ROUTE_PARAMS, allowMultiPart = true, pendingPayments = routes1.drop(1)) val routes2 = sender.expectMessageType[RouteResponse].routes - assert(routes2 == routes1.tail) - router ! RouteRequest(sender.ref, a, recipient, DEFAULT_ROUTE_PARAMS, allowMultiPart = true, pendingPayments = Seq(routes1.head, routes2.head.copy(amount = routes2.head.amount - 25_000.msat))) + assert(routes2.length == 1) + assert(routes2 == routes1.take(1)) + router ! RouteRequest(sender.ref, a, recipient, DEFAULT_ROUTE_PARAMS, allowMultiPart = true, pendingPayments = routes2.head.copy(amount = routes2.head.amount - 25_000.msat) +: routes1.drop(1)) val routes3 = sender.expectMessageType[RouteResponse].routes assert(routes3.length == 1) assert(routes3.head.amount == 25_000.msat) @@ -611,7 +612,7 @@ class RouterSpec extends BaseRouterSpec { router ! RouteRequest(sender.ref, a, recipient, routeParams1, allowMultiPart = true) val routes1 = sender.expectMessageType[RouteResponse].routes assert(routes1.length == 2) - assert(routes1.map(r => r.blindedFee + r.channelFee(false)) == Seq(510 msat, 800 msat)) + assert(routes1.map(r => r.blindedFee + r.channelFee(false)) == Seq(800 msat, 510 msat)) val routeParams2 = DEFAULT_ROUTE_PARAMS.copy(boundaries = SearchBoundaries(1000 msat, 0.0, 6, CltvExpiryDelta(1008))) router ! RouteRequest(sender.ref, a, recipient, routeParams2, allowMultiPart = true, pendingPayments = Seq(routes1.head)) sender.expectMessage(PaymentRouteNotFound(RouteNotFound))