Skip to content

Commit

Permalink
Split MPP by maximizing expected delivered amount
Browse files Browse the repository at this point in the history
As suggested by @renepickhardt in #2785
  • Loading branch information
thomash-acinq committed Dec 5, 2023
1 parent d4a498c commit 3f46677
Show file tree
Hide file tree
Showing 10 changed files with 126 additions and 27 deletions.
1 change: 1 addition & 0 deletions eclair-core/src/main/resources/reference.conf
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,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"
}
}

Expand Down
7 changes: 6 additions & 1 deletion eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,12 @@ object NodeParams extends Logging {
},
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"))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,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)) ::
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import com.softwaremill.quicklens.ModifyPimp
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
import fr.acinq.eclair.Logs.LogCategory
import fr.acinq.eclair._
import fr.acinq.eclair.message.SendingMessage
import fr.acinq.eclair.payment.send._
import fr.acinq.eclair.router.Graph.GraphStructure.DirectedGraph.graphEdgeToHop
import fr.acinq.eclair.router.Graph.GraphStructure.{DirectedGraph, GraphEdge}
Expand Down Expand Up @@ -420,7 +419,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) =>
Expand All @@ -446,16 +445,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.
Expand All @@ -464,6 +463,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))
Expand Down
15 changes: 14 additions & 1 deletion eclair-core/src/main/scala/fr/acinq/eclair/router/Router.scala
Original file line number Diff line number Diff line change
Expand Up @@ -551,7 +551,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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ object TestConstants {
mpp = MultiPartParams(
minPartAmount = 15000000 msat,
maxParts = 10,
splittingStrategy = MultiPartParams.FullCapacity
),
experimentName = "alice-test-experiment",
experimentPercentage = 100))),
Expand Down Expand Up @@ -369,6 +370,7 @@ object TestConstants {
mpp = MultiPartParams(
minPartAmount = 15000000 msat,
maxParts = 10,
splittingStrategy = MultiPartParams.FullCapacity
),
experimentName = "bob-test-experiment",
experimentPercentage = 100))),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ abstract class IntegrationSpec extends TestKitBaseClass with BitcoindService wit
capacityFactor = 0,
hopCost = RelayFees(0 msat, 0),
)),
mpp = MultiPartParams(15000000 msat, 6),
mpp = MultiPartParams(15000000 msat, 6, MultiPartParams.FullCapacity),
experimentName = "my-test-experiment",
experimentPercentage = 100
).getDefaultRouteParams
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -723,7 +723,7 @@ object MultiPartPaymentLifecycleSpec {
6,
CltvExpiryDelta(1008)),
Left(WeightRatios(1, 0, 0, 0, RelayFees(0 msat, 0))),
MultiPartParams(1000 msat, 5),
MultiPartParams(1000 msat, 5, MultiPartParams.FullCapacity),
experimentName = "my-test-experiment",
experimentPercentage = 100
).getDefaultRouteParams
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ class PaymentLifecycleSpec extends BaseRouterSpec {
randomize = false,
boundaries = SearchBoundaries(100 msat, 0.0, 20, CltvExpiryDelta(2016)),
Left(WeightRatios(1, 0, 0, 0, RelayFees(0 msat, 0))),
MultiPartParams(10_000 msat, 5),
MultiPartParams(10_000 msat, 5, MultiPartParams.FullCapacity),
"my-test-experiment",
experimentPercentage = 100
).getDefaultRouteParams
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -969,7 +969,7 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution {
makeEdge(4L, a, b, 100 msat, 20, minHtlc = 1 msat, balance_opt = Some(16000 msat)),
))
// 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))
Expand All @@ -985,7 +985,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))
}
}
Expand Down Expand Up @@ -1112,7 +1112,7 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution {
))

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)
Expand Down Expand Up @@ -1244,7 +1244,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 = 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),
Expand Down Expand Up @@ -1364,22 +1364,22 @@ 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)))
}
{
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 => {
Expand Down Expand Up @@ -1469,7 +1469,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)
))
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)
Expand Down Expand Up @@ -1599,6 +1599,55 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution {
}
}

test("calculate multipart route to remote node using max expected amount splitting strategy") {
// +----- B -----+
// | |
// A----- C ---- E
// | |
// +----- D -----+
val (amount, maxFee) = (50000 msat, 1000 msat)
val g = 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),
))

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))
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), (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 = 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),
))

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))
routes.foreach(println)
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") {
// +-----------------+
// | |
Expand Down Expand Up @@ -1927,7 +1976,7 @@ object RouteCalculationSpec {
randomize = false,
boundaries = SearchBoundaries(21000 msat, 0.03, 6, CltvExpiryDelta(2016)),
Left(NO_WEIGHT_RATIOS),
MultiPartParams(1000 msat, 10),
MultiPartParams(1000 msat, 10, MultiPartParams.FullCapacity),
experimentName = "my-test-experiment",
experimentPercentage = 100).getDefaultRouteParams

Expand Down

0 comments on commit 3f46677

Please sign in to comment.