Skip to content
Merged
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
9 changes: 8 additions & 1 deletion docs/release-notes/eclair-vnext.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@

## Major changes

<insert 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 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

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 @@ -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 {
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 @@ -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"))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,17 @@ 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] = (
("minPartAmount" | millisatoshi) ::
("maxParts" | int32)).as[MultiPartParams]
("maxParts" | int32) ::
("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)) ::
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
7 changes: 0 additions & 7 deletions eclair-core/src/main/scala/fr/acinq/eclair/router/Graph.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
138 changes: 100 additions & 38 deletions eclair-core/src/main/scala/fr/acinq/eclair/router/RouteCalculation.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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 = {
Expand All @@ -429,63 +423,124 @@ 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) =>
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(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
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)
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 = 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))
}
if (candidate.amount >= routeParams.mpp.minPartAmount.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)
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 =>
candidate.amount.min(amountLeft)
}
// 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 totalMaximum = candidates.map(_.maxAmount).sum
if (amountLeft == 0.msat) {
Right(candidates.map(_.route))
} else if (totalMaximum < amount) {
Left(RouteNotFound)
} else {
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) =>
if (amountLeft > 0.msat) {
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
}
}
Right(routes)
}
}

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 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 {
candidate.copy(amount = candidate.amount.min(amount))
// 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)
}
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)
total + dp / p
}
if (d > 0.0) {
low = x
} else {
high = x
}
}
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))
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] = {
val usedCapacity = mutable.Map.empty[ShortChannelId, MilliSatoshi]
Expand All @@ -497,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)
}
Expand Down
Loading