Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Split MPP by maximizing expected delivered amount #2792

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/release-notes/eclair-vnext.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ eclair.channel.channel-update {

This feature leaks a bit of information about the balance when the channel is almost empty, if you do not wish to use it, set `eclair.channel.channel-update.balance-thresholds = []`.

### 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).

### API changes

- `bumpforceclose` can be used to make a force-close confirm faster, by spending the anchor output (#2743)
Expand Down
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" // Can be either "full-capacity", "randomize" or "max-expected-amount"
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought that using "randomize" by default wouldn't change the current behavior as the default sets "randomize-route-selection = true", however the code flips this "randomize" flag in several places. I need to think more about it to not change the default behavior, any ideas?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's true, when the default configuration fails, we fall back to a retry using the randomize flag set to true. IIRC we only override this to set it to true, is that correct or am I missing some cases? As long as the first attempts use the configured values, I think it's ok if eclair has a fallback on failure that uses the algorithm we think as being the best option.

}
}

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)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For a uniform distribution between 0 and cap there is indeed only one max in the expected value curve and thus the derrivative on that domain has indeed only one 0.

For two channels I computed that setting the derrivative of the expected value to 0 is equivalent to

1/x = 1/(c_1-x) + 1/(c_2-x)

assuming this generalizes to several channels (because of the product rule of derrivatives for the probability of the path this very much looks like this (I think I computed that before)). Thus I think it is indeed reasonable to look that one only has to check the sign of the term 1/x - 1/(c_1 - x) - ... - 1/(c_n - x) In deed I compared it to my local python code and the results matched also for more than 2 channels

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we consider the polynomial P(x) = x(c0 - x)(c1 - x)(c2 - x)..., the full derivative is actually P(x) / x - P(x) / (c0 - x) - P(x) / (c1 - x) - P(x) / (c2 - x) - ... = P(x) (1 / x - 1 / (c0 - x) - 1 / (c1 - x) - 1 / (c2 - x) - ...). We know that P(x) is positive on (0, min(c0, c1, c2, ...)) so the sign is the same as 1 / x - 1 / (c0 - x) - 1 / (c1 - x) - 1 / (c2 - x) - ....

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for laying out the proof. It's funny as I remember trying to find a formular during last summer to solve this analytically and I recognizedc the same structure. But I couldn't non numerically solve for x which is why I stopped there.

However I still wonder how does the computation change if for example the channel c_2 has a non zero low estimate? Evaluating the full polynomial n times to find the max works easily when using conditional probabilities. I have a travel day tomorrow and might sit down and try your approach for the conditional probabilities. I am sure a similar formula can be found.

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

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 = 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))
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 +1977,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
Loading