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

Add limited support for Blip18 inbound fees #2933

Open
wants to merge 5 commits 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
2 changes: 2 additions & 0 deletions eclair-core/src/main/resources/reference.conf
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,8 @@ eclair {
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
}
blip18-inbound-fees = false
exclude-channels-with-positive-inbound-fees = false
}

// The path-finding algo uses one or more sets of parameters named experiments. Each experiment has a percentage
Expand Down
5 changes: 3 additions & 2 deletions eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala
Original file line number Diff line number Diff line change
Expand Up @@ -461,8 +461,9 @@ object NodeParams extends Logging {
Satoshi(config.getLong("mpp.min-amount-satoshis")).toMilliSatoshi,
config.getInt("mpp.max-parts")),
experimentName = name,
experimentPercentage = config.getInt("percentage"))

experimentPercentage = config.getInt("percentage"),
blip18InboundFees = config.getBoolean("blip18-inbound-fees"),
excludePositiveInboundFees = config.getBoolean("exclude-channels-with-positive-inbound-fees"))

def getPathFindingExperimentConf(config: Config): PathFindingExperimentConf = {
val experiments = config.root.asScala.keys.map(name => name -> getPathFindingConf(config.getConfig(name), name))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,14 @@ object Relayer extends Logging {
require(feeProportionalMillionths >= 0.0, "feeProportionalMillionths must be nonnegative")
}

case class InboundFees(feeBase: MilliSatoshi, feeProportionalMillionths: Long)

object InboundFees {
def apply(feeBaseInt32: Int, feeProportionalMillionthsInt32: Int): InboundFees = {
InboundFees(MilliSatoshi(feeBaseInt32), feeProportionalMillionthsInt32)
}
}

case class AsyncPaymentsParams(holdTimeoutBlocks: Int, cancelSafetyBeforeTimeout: CltvExpiryDelta)

case class RelayParams(publicChannelFees: RelayFees,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -455,8 +455,8 @@ object PaymentLifecycle {
override val amount = route.fold(_.amount, _.amount)

def printRoute(): String = route match {
case Left(PredefinedChannelRoute(_, _, channels, _)) => channels.mkString("->")
case Left(PredefinedNodeRoute(_, nodes, _)) => nodes.mkString("->")
case Left(PredefinedChannelRoute(_, _, channels, _, _, _)) => channels.mkString("->")
case Left(PredefinedNodeRoute(_, nodes, _, _, _)) => nodes.mkString("->")
case Right(route) => route.printNodes()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,9 @@ object EclairInternalsSerializer {
("heuristicsParams" | either(bool(8), weightRatiosCodec, heuristicsConstantsCodec)) ::
("mpp" | multiPartParamsCodec) ::
("experimentName" | utf8_32) ::
("experimentPercentage" | int32)).as[PathFindingConf]
("experimentPercentage" | int32) ::
("blip18InboundFees" | bool(8)) ::
("excludePositiveInboundFees" | bool(8))).as[PathFindingConf]

val pathFindingExperimentConfCodec: Codec[PathFindingExperimentConf] = (
"experiments" | listOfN(int32, pathFindingConfCodec).xmap[Map[String, PathFindingConf]](_.map(e => e.experimentName -> e).toMap, _.values.toList)
Expand Down
25 changes: 19 additions & 6 deletions eclair-core/src/main/scala/fr/acinq/eclair/router/Graph.scala
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ import fr.acinq.bitcoin.scalacompat.{Btc, BtcDouble, MilliBtc, Satoshi}
import fr.acinq.eclair._
import fr.acinq.eclair.payment.Invoice
import fr.acinq.eclair.payment.relay.Relayer.RelayFees
import fr.acinq.eclair.router.Graph.GraphStructure.{GraphEdge, DirectedGraph}
import fr.acinq.eclair.router.Graph.GraphStructure.{DirectedGraph, GraphEdge}
import fr.acinq.eclair.router.Router.HopRelayParams
import fr.acinq.eclair.router.Router._
import fr.acinq.eclair.wire.protocol.{ChannelUpdate, NodeAnnouncement}

Expand Down Expand Up @@ -120,10 +121,11 @@ object Graph {
wr: Either[WeightRatios, HeuristicsConstants],
currentBlockHeight: BlockHeight,
boundaries: RichWeight => Boolean,
includeLocalChannelCost: Boolean): Seq[WeightedPath] = {
includeLocalChannelCost: Boolean,
excludePositiveInboundFees: Boolean = false): Seq[WeightedPath] = {
// find the shortest path (k = 0)
val targetWeight = RichWeight(amount, 0, CltvExpiryDelta(0), 1.0, 0 msat, 0 msat, 0.0)
val shortestPath = dijkstraShortestPath(graph, sourceNode, targetNode, ignoredEdges, ignoredVertices, extraEdges, targetWeight, boundaries, currentBlockHeight, wr, includeLocalChannelCost)
val shortestPath = dijkstraShortestPath(graph, sourceNode, targetNode, ignoredEdges, ignoredVertices, extraEdges, targetWeight, boundaries, currentBlockHeight, wr, includeLocalChannelCost, excludePositiveInboundFees)
if (shortestPath.isEmpty) {
return Seq.empty // if we can't even find a single path, avoid returning a Seq(Seq.empty)
}
Expand Down Expand Up @@ -162,7 +164,7 @@ object Graph {
val alreadyExploredVertices = rootPathEdges.map(_.desc.b).toSet
val rootPathWeight = pathWeight(sourceNode, rootPathEdges, amount, currentBlockHeight, wr, includeLocalChannelCost)
// find the "spur" path, a sub-path going from the spur node to the target avoiding previously found sub-paths
val spurPath = dijkstraShortestPath(graph, sourceNode, spurNode, ignoredEdges ++ alreadyExploredEdges, ignoredVertices ++ alreadyExploredVertices, extraEdges, rootPathWeight, boundaries, currentBlockHeight, wr, includeLocalChannelCost)
val spurPath = dijkstraShortestPath(graph, sourceNode, spurNode, ignoredEdges ++ alreadyExploredEdges, ignoredVertices ++ alreadyExploredVertices, extraEdges, rootPathWeight, boundaries, currentBlockHeight, wr, includeLocalChannelCost, excludePositiveInboundFees)
if (spurPath.nonEmpty) {
val completePath = spurPath ++ rootPathEdges
val candidatePath = WeightedPath(completePath, pathWeight(sourceNode, completePath, amount, currentBlockHeight, wr, includeLocalChannelCost))
Expand Down Expand Up @@ -210,7 +212,8 @@ object Graph {
boundaries: RichWeight => Boolean,
currentBlockHeight: BlockHeight,
wr: Either[WeightRatios, HeuristicsConstants],
includeLocalChannelCost: Boolean): Seq[GraphEdge] = {
includeLocalChannelCost: Boolean,
excludePositiveInboundFees: Boolean): Seq[GraphEdge] = {
// the graph does not contain source/destination nodes
val sourceNotInGraph = !g.containsVertex(sourceNode) && !extraEdges.exists(_.desc.a == sourceNode)
val targetNotInGraph = !g.containsVertex(targetNode) && !extraEdges.exists(_.desc.b == targetNode)
Expand Down Expand Up @@ -251,7 +254,8 @@ object Graph {
edge.params.htlcMaximum_opt.forall(current.weight.amount <= _) &&
current.weight.amount >= edge.params.htlcMinimum &&
!ignoredEdges.contains(edge.desc) &&
!ignoredVertices.contains(neighbor)) {
!ignoredVertices.contains(neighbor) &&
(!excludePositiveInboundFees || g.getBackEdge(edge).flatMap(_.getChannelUpdate).flatMap(_.blip18InboundFees_opt).forall(i => i.feeBase.toLong <= 0 && i.feeProportionalMillionths <= 0))) {
// NB: this contains the amount (including fees) that will need to be sent to `neighbor`, but the amount that
// will be relayed through that edge is the one in `currentWeight`.
val neighborWeight = addEdgeWeight(sourceNode, edge, current.weight, currentBlockHeight, wr, includeLocalChannelCost)
Expand Down Expand Up @@ -595,6 +599,11 @@ object Graph {
).flatten.min.max(0 msat)

def fee(amount: MilliSatoshi): MilliSatoshi = params.fee(amount)

def getChannelUpdate: Option[ChannelUpdate] = params match {
case HopRelayParams.FromAnnouncement(update, _) => Some(update)
case _ => None
}
}

object GraphEdge {
Expand Down Expand Up @@ -686,6 +695,10 @@ object Graph {
def getEdge(desc: ChannelDesc): Option[GraphEdge] =
vertices.get(desc.b).flatMap(_.incomingEdges.get(desc))

def getBackEdge(desc: ChannelDesc): Option[GraphEdge] = getEdge(desc.copy(a = desc.b, b = desc.a))

def getBackEdge(edge: GraphEdge): Option[GraphEdge] = getBackEdge(edge.desc)

/**
* @param keyA the key associated with the starting vertex
* @param keyB the key associated with the ending vertex
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,14 @@ object RouteCalculation {
}
}

def validatePositiveInboundFees(route: Route, excludePositiveInboundFees: Boolean): Try[Route] = {
if (!excludePositiveInboundFees || route.hops.forall(hop => hop.params.inboundFees_opt.forall(i => i.feeBase <= 0.msat && i.feeProportionalMillionths <= 0))) {
Success(route)
} else {
Failure(new IllegalArgumentException("Route contains hops with positive inbound fees"))
}
}

Logs.withMdc(log)(Logs.mdc(
category_opt = Some(LogCategory.PAYMENT),
parentPaymentId_opt = fr.paymentContext.map(_.parentId),
Expand All @@ -69,22 +77,31 @@ object RouteCalculation {
val g = extraEdges.foldLeft(d.graphWithBalances.graph) { case (g: DirectedGraph, e: GraphEdge) => g.addEdge(e) }

fr.route match {
case PredefinedNodeRoute(amount, hops, maxFee_opt) =>
case PredefinedNodeRoute(amount, hops, maxFee_opt, blip18InboundFees, excludePositiveInboundFees) =>
// split into sublists [(a,b),(b,c), ...] then get the edges between each of those pairs
hops.sliding(2).map { case List(v1, v2) => g.getEdgesBetween(v1, v2) }.toList match {
case edges if edges.nonEmpty && edges.forall(_.nonEmpty) =>
// select the largest edge (using balance when available, otherwise capacity).
val selectedEdges = edges.map(es => es.maxBy(e => e.balance_opt.getOrElse(e.capacity.toMilliSatoshi)))
val hops = selectedEdges.map(e => ChannelHop(getEdgeRelayScid(d, localNodeId, e), e.desc.a, e.desc.b, e.params))
validateMaxRouteFee(Route(amount, hops, None), maxFee_opt) match {
case Success(route) => ctx.sender() ! RouteResponse(route :: Nil)
val route = if (blip18InboundFees) {
validatePositiveInboundFees(routeWithInboundFees(amount, hops, g), excludePositiveInboundFees)
} else {
Success(Route(amount, hops, None))
}
route match {
case Success(r) =>
validateMaxRouteFee(r, maxFee_opt) match {
case Success(validatedRoute) => ctx.sender() ! RouteResponse(validatedRoute :: Nil)
case Failure(f) => ctx.sender() ! Status.Failure(f)
}
case Failure(f) => ctx.sender() ! Status.Failure(f)
}
case _ =>
// some nodes in the supplied route aren't connected in our graph
ctx.sender() ! Status.Failure(new IllegalArgumentException("Not all the nodes in the supplied route are connected with public channels"))
}
case PredefinedChannelRoute(amount, targetNodeId, shortChannelIds, maxFee_opt) =>
case PredefinedChannelRoute(amount, targetNodeId, shortChannelIds, maxFee_opt, blip18InboundFees, excludePositiveInboundFees) =>
val (end, hops) = shortChannelIds.foldLeft((localNodeId, Seq.empty[ChannelHop])) {
case ((currentNode, previousHops), shortChannelId) =>
val channelDesc_opt = d.resolve(shortChannelId) match {
Expand All @@ -108,8 +125,17 @@ object RouteCalculation {
if (end != targetNodeId || hops.length != shortChannelIds.length) {
ctx.sender() ! Status.Failure(new IllegalArgumentException("The sequence of channels provided cannot be used to build a route to the target node"))
} else {
validateMaxRouteFee(Route(amount, hops, None), maxFee_opt) match {
case Success(route) => ctx.sender() ! RouteResponse(route :: Nil)
val route = if (blip18InboundFees) {
validatePositiveInboundFees(routeWithInboundFees(amount, hops, g), excludePositiveInboundFees)
} else {
Success(Route(amount, hops, None))
}
route match {
case Success(r) =>
validateMaxRouteFee(r, maxFee_opt) match {
case Success(validatedRoute) => ctx.sender() ! RouteResponse(validatedRoute :: Nil)
case Failure(f) => ctx.sender() ! Status.Failure(f)
}
case Failure(f) => ctx.sender() ! Status.Failure(f)
}
}
Expand Down Expand Up @@ -296,11 +322,39 @@ object RouteCalculation {
routeParams: RouteParams,
currentBlockHeight: BlockHeight): Try[Seq[Route]] = Try {
findRouteInternal(g, localNodeId, targetNodeId, amount, maxFee, numRoutes, extraEdges, ignoredEdges, ignoredVertices, routeParams, currentBlockHeight) match {
case Right(routes) => routes.map(route => Route(amount, route.path.map(graphEdgeToHop), None))
case Right(routes) => routes.map { route =>
if (routeParams.blip18InboundFees)
routeWithInboundFees(amount, route.path.map(graphEdgeToHop), g)
else
Route(amount, route.path.map(graphEdgeToHop), None)
}
case Left(ex) => return Failure(ex)
}
}

private def routeWithInboundFees(amount: MilliSatoshi, routeHops: Seq[ChannelHop], g: DirectedGraph): Route = {
if (routeHops.tail.isEmpty) {
Route(amount, routeHops, None)
} else {
val hops = routeHops.reverse
val updatedHops = routeHops.head :: hops.zip(hops.tail).foldLeft(List.empty[ChannelHop]) { (hops, x) =>
val (curr, prev) = x
val backEdge_opt = g.getBackEdge(ChannelDesc(prev.shortChannelId, prev.nodeId, prev.nextNodeId))
val hop = curr.copy(params = curr.params match {
case hopParams: HopRelayParams.FromAnnouncement =>
backEdge_opt
.flatMap(_.getChannelUpdate)
.map(u => hopParams.copy(inboundFees_opt = u.blip18InboundFees_opt))
.getOrElse(hopParams)
case hopParams => hopParams
})

hop :: hops
}
Route(amount, updatedHops, None)
}
}

@tailrec
private def findRouteInternal(g: DirectedGraph,
localNodeId: PublicKey,
Expand All @@ -325,7 +379,7 @@ object RouteCalculation {

val boundaries: RichWeight => Boolean = { weight => feeOk(weight.amount - amount) && lengthOk(weight.length) && cltvOk(weight.cltv) }

val foundRoutes: Seq[Graph.WeightedPath] = Graph.yenKshortestPaths(g, localNodeId, targetNodeId, amount, ignoredEdges, ignoredVertices, extraEdges, numRoutes, routeParams.heuristics, currentBlockHeight, boundaries, routeParams.includeLocalChannelCost)
val foundRoutes: Seq[Graph.WeightedPath] = Graph.yenKshortestPaths(g, localNodeId, targetNodeId, amount, ignoredEdges, ignoredVertices, extraEdges, numRoutes, routeParams.heuristics, currentBlockHeight, boundaries, routeParams.includeLocalChannelCost, routeParams.excludePositiveInboundFees)
if (foundRoutes.nonEmpty) {
val (directRoutes, indirectRoutes) = foundRoutes.partition(_.path.length == 1)
val routes = if (routeParams.randomize) {
Expand Down Expand Up @@ -416,7 +470,11 @@ object RouteCalculation {
case Right(routes) =>
// 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 {
case Right(routes) if validateMultiPartRoute(amount, maxFee, routes, routeParams.includeLocalChannelCost) => Right(routes)
case Right(routes) if validateMultiPartRoute(amount, maxFee, routes, routeParams.includeLocalChannelCost) =>
if (routeParams.blip18InboundFees)
Right(routes.map(r => routeWithInboundFees(r.amount, r.hops, g)))
else
Right(routes)
case _ => Left(RouteNotFound)
}
case Left(ex) => Left(ex)
Expand Down
Loading
Loading