From 2e854e36ec7bc8b37dda900896af4c56871f62d2 Mon Sep 17 00:00:00 2001 From: rorp Date: Sun, 20 Oct 2024 11:28:34 -0700 Subject: [PATCH 1/2] Add limited support for Blip18 inbound fees --- eclair-core/src/main/resources/reference.conf | 2 + .../scala/fr/acinq/eclair/NodeParams.scala | 5 +- .../acinq/eclair/payment/relay/Relayer.scala | 8 + .../payment/send/PaymentLifecycle.scala | 4 +- .../remote/EclairInternalsSerializer.scala | 4 +- .../scala/fr/acinq/eclair/router/Graph.scala | 17 +- .../eclair/router/RouteCalculation.scala | 77 +++- .../scala/fr/acinq/eclair/router/Router.scala | 34 +- .../wire/protocol/LightningMessageTypes.scala | 3 + .../eclair/wire/protocol/RoutingTlv.scala | 11 +- .../router/Blip18RouteCalculationSpec.scala | 371 ++++++++++++++++++ .../eclair/router/RouteCalculationSpec.scala | 2 +- .../acinq/eclair/api/handlers/Payment.scala | 8 +- 13 files changed, 513 insertions(+), 33 deletions(-) create mode 100644 eclair-core/src/test/scala/fr/acinq/eclair/router/Blip18RouteCalculationSpec.scala diff --git a/eclair-core/src/main/resources/reference.conf b/eclair-core/src/main/resources/reference.conf index 77dd6f058c..d65412faf2 100644 --- a/eclair-core/src/main/resources/reference.conf +++ b/eclair-core/src/main/resources/reference.conf @@ -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 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 09b4fc9261..3f5fe8b85f 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala @@ -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)) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/Relayer.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/Relayer.scala index 1600e185cd..2238252c32 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/Relayer.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/Relayer.scala @@ -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, diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/PaymentLifecycle.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/PaymentLifecycle.scala index 442ce2f3fc..22e0ba27f6 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/PaymentLifecycle.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/PaymentLifecycle.scala @@ -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() } } 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 0f66a6f93f..69b24e412a 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 @@ -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) 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 89af943807..461f33519d 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 @@ -120,10 +120,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) } @@ -162,7 +163,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)) @@ -210,7 +211,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) @@ -251,7 +253,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).forall(e => e.params.inboundFees_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) @@ -686,6 +689,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 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 8c8e524afb..0ffa4f301e 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 @@ -29,6 +29,7 @@ import fr.acinq.eclair.router.Graph.GraphStructure.{DirectedGraph, GraphEdge} import fr.acinq.eclair.router.Graph.{InfiniteLoop, MessagePath, NegativeProbability, RichWeight} import fr.acinq.eclair.router.Monitoring.{Metrics, Tags} import fr.acinq.eclair.router.Router._ +import fr.acinq.eclair.wire.protocol.ChannelUpdateTlv.Blip18InboundFee import kamon.tag.TagSet import scala.annotation.tailrec @@ -59,6 +60,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), @@ -70,22 +79,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 { @@ -109,8 +127,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) } } @@ -306,11 +333,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 maybeEdge = g.getBackEdge(ChannelDesc(prev.shortChannelId, prev.nodeId, prev.nextNodeId)) + val hop = curr.copy(params = curr.params match { + case hopParams: HopRelayParams.FromAnnouncement => + maybeEdge match { + case Some(backEdge) => hopParams.copy(updatedInboundFees_opt = backEdge.params.inboundFees_opt) + case _ => hopParams + } + case hopParams => hopParams + }) + + hop :: hops + } + Route(amount, updatedHops, None) + } + } + @tailrec private def findRouteInternal(g: DirectedGraph, localNodeId: PublicKey, @@ -335,7 +390,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) { @@ -426,7 +481,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) 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 4974931a35..94b48b14ac 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 @@ -344,14 +344,18 @@ object Router { heuristics: Either[WeightRatios, HeuristicsConstants], mpp: MultiPartParams, experimentName: String, - experimentPercentage: Int) { + experimentPercentage: Int, + blip18InboundFees: Boolean = false, + excludePositiveInboundFees: Boolean = false) { def getDefaultRouteParams: RouteParams = RouteParams( randomize = randomize, boundaries = boundaries, heuristics = heuristics, mpp = mpp, experimentName = experimentName, - includeLocalChannelCost = false + includeLocalChannelCost = false, + blip18InboundFees = blip18InboundFees, + excludePositiveInboundFees = excludePositiveInboundFees ) } @@ -473,7 +477,13 @@ object Router { // @formatter:off def cltvExpiryDelta: CltvExpiryDelta def relayFees: Relayer.RelayFees - final def fee(amount: MilliSatoshi): MilliSatoshi = nodeFee(relayFees, amount) + def inboundFees_opt: Option[Relayer.InboundFees] + final def fee(amount: MilliSatoshi): MilliSatoshi = { + val outFee = nodeFee(relayFees, amount) + val inFee = inboundFees_opt.map(i => nodeFee(i.feeBase, i.feeProportionalMillionths, amount + outFee)).getOrElse(0 msat) + val totalFee = outFee + inFee + if (totalFee.toLong < 0) 0 msat else totalFee + } def htlcMinimum: MilliSatoshi def htlcMaximum_opt: Option[MilliSatoshi] // @formatter:on @@ -481,17 +491,20 @@ object Router { object HopRelayParams { /** We learnt about this channel from a channel_update. */ - case class FromAnnouncement(channelUpdate: ChannelUpdate) extends HopRelayParams { + case class FromAnnouncement(channelUpdate: ChannelUpdate, updatedInboundFees_opt: Option[Relayer.InboundFees] = None) extends HopRelayParams { override val cltvExpiryDelta = channelUpdate.cltvExpiryDelta override val relayFees = channelUpdate.relayFees + override val inboundFees_opt = updatedInboundFees_opt orElse channelUpdate.inboundFees_opt override val htlcMinimum = channelUpdate.htlcMinimumMsat override val htlcMaximum_opt = Some(channelUpdate.htlcMaximumMsat) + } /** We learnt about this hop from hints in an invoice. */ case class FromHint(extraHop: Invoice.ExtraEdge) extends HopRelayParams { override val cltvExpiryDelta = extraHop.cltvExpiryDelta override val relayFees = extraHop.relayFees + override val inboundFees_opt = None override val htlcMinimum = extraHop.htlcMinimum override val htlcMaximum_opt = extraHop.htlcMaximum_opt } @@ -499,6 +512,7 @@ object Router { def areSame(a: HopRelayParams, b: HopRelayParams, ignoreHtlcSize: Boolean = false): Boolean = a.cltvExpiryDelta == b.cltvExpiryDelta && a.relayFees == b.relayFees && + a.inboundFees_opt == b.inboundFees_opt && (ignoreHtlcSize || (a.htlcMinimum == b.htlcMinimum && a.htlcMaximum_opt == b.htlcMaximum_opt)) } @@ -559,7 +573,9 @@ object Router { heuristics: Either[WeightRatios, HeuristicsConstants], mpp: MultiPartParams, experimentName: String, - includeLocalChannelCost: Boolean) { + includeLocalChannelCost: Boolean, + blip18InboundFees: Boolean, + excludePositiveInboundFees: Boolean) { def getMaxFee(amount: MilliSatoshi): MilliSatoshi = { // The payment fee must satisfy either the flat fee or the proportional fee, not necessarily both. boundaries.maxFeeFlat.max(amount * boundaries.maxFeeProportional) @@ -668,12 +684,14 @@ object Router { def amount: MilliSatoshi def targetNodeId: PublicKey def maxFee_opt: Option[MilliSatoshi] - } - case class PredefinedNodeRoute(amount: MilliSatoshi, nodes: Seq[PublicKey], maxFee_opt: Option[MilliSatoshi] = None) extends PredefinedRoute { + def blip18InboundFees: Boolean + def excludePositiveInboundFees: Boolean + } + case class PredefinedNodeRoute(amount: MilliSatoshi, nodes: Seq[PublicKey], maxFee_opt: Option[MilliSatoshi] = None, blip18InboundFees: Boolean = false, excludePositiveInboundFees: Boolean = false) extends PredefinedRoute { override def isEmpty = nodes.isEmpty override def targetNodeId: PublicKey = nodes.last } - case class PredefinedChannelRoute(amount: MilliSatoshi, targetNodeId: PublicKey, channels: Seq[ShortChannelId], maxFee_opt: Option[MilliSatoshi] = None) extends PredefinedRoute { + case class PredefinedChannelRoute(amount: MilliSatoshi, targetNodeId: PublicKey, channels: Seq[ShortChannelId], maxFee_opt: Option[MilliSatoshi] = None, blip18InboundFees: Boolean = false, excludePositiveInboundFees: Boolean = false) extends PredefinedRoute { override def isEmpty = channels.isEmpty } // @formatter:on diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala index ace095ca1a..e884ff5151 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala @@ -543,6 +543,9 @@ case class ChannelUpdate(signature: ByteVector64, def toStringShort: String = s"cltvExpiryDelta=$cltvExpiryDelta,feeBase=$feeBaseMsat,feeProportionalMillionths=$feeProportionalMillionths" def relayFees: Relayer.RelayFees = Relayer.RelayFees(feeBase = feeBaseMsat, feeProportionalMillionths = feeProportionalMillionths) + + def inboundFees_opt: Option[Relayer.InboundFees] = + tlvStream.get[ChannelUpdateTlv.Blip18InboundFee].map(blip18 => Relayer.InboundFees(blip18.feeBase, blip18.feeProportionalMillionths)) } object ChannelUpdate { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/RoutingTlv.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/RoutingTlv.scala index d157a388b9..4089b043fa 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/RoutingTlv.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/RoutingTlv.scala @@ -53,7 +53,16 @@ object ChannelAnnouncementTlv { sealed trait ChannelUpdateTlv extends Tlv object ChannelUpdateTlv { - val channelUpdateTlvCodec: Codec[TlvStream[ChannelUpdateTlv]] = tlvStream(discriminated[ChannelUpdateTlv].by(varint)) + case class Blip18InboundFee(feeBase: Int, feeProportionalMillionths: Int) extends ChannelUpdateTlv + + private val blip18InboundFeeCodec: Codec[Blip18InboundFee] = tlvField(Codec( + ("feeBase" | int32) :: + ("feeProportionalMillionths" | int32) + ).as[Blip18InboundFee]) + + val channelUpdateTlvCodec: Codec[TlvStream[ChannelUpdateTlv]] = TlvCodecs.tlvStream(discriminated.by(varint) + .typecase(UInt64(55555), blip18InboundFeeCodec) + ) } sealed trait GossipTimestampFilterTlv extends Tlv diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/router/Blip18RouteCalculationSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/router/Blip18RouteCalculationSpec.scala new file mode 100644 index 0000000000..68c7e9a41c --- /dev/null +++ b/eclair-core/src/test/scala/fr/acinq/eclair/router/Blip18RouteCalculationSpec.scala @@ -0,0 +1,371 @@ +package fr.acinq.eclair.router + +import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey +import fr.acinq.bitcoin.scalacompat.{Block, Satoshi, SatoshiLong} +import fr.acinq.eclair.payment.IncomingPaymentPacket.{ChannelRelayPacket, FinalPacket, decrypt} +import fr.acinq.eclair.payment.OutgoingPaymentPacket.buildOutgoingPayment +import fr.acinq.eclair.payment.PaymentPacketSpec.{paymentHash, paymentSecret} +import fr.acinq.eclair.payment.relay.Relayer.RelayFees +import fr.acinq.eclair.payment.send.ClearRecipient +import fr.acinq.eclair.router.Graph.GraphStructure.{DirectedGraph, GraphEdge} +import fr.acinq.eclair.router.Graph.WeightRatios +import fr.acinq.eclair.router.RouteCalculation.{findMultiPartRoute, findRoute} +import fr.acinq.eclair.router.Router._ +import fr.acinq.eclair.transactions.Transactions +import fr.acinq.eclair.wire.protocol.PaymentOnion.FinalPayload +import fr.acinq.eclair.wire.protocol._ +import fr.acinq.eclair.{BlockHeight, CltvExpiry, CltvExpiryDelta, Features, MilliSatoshi, MilliSatoshiLong, RealShortChannelId, ShortChannelId, TestConstants, TimestampSecond, TimestampSecondLong, randomBytes32, randomKey} +import org.scalatest.ParallelTestExecution +import org.scalatest.funsuite.AnyFunSuite + +import scala.util.{Failure, Success} + +class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution { + + import Blip18RouteCalculationSpec._ + + val (priv_a, priv_b, priv_c, priv_d, priv_e, priv_f) = (randomKey(), randomKey(), randomKey(), randomKey(), randomKey(), randomKey()) + val (a, b, c, d, e, f) = (priv_a.publicKey, priv_b.publicKey, priv_c.publicKey, priv_d.publicKey, priv_e.publicKey, priv_f.publicKey) + + test("test findRoute with Blip18 enabled") { + // extracted from the LND code base + val g = DirectedGraph(Seq( + makeEdge(10L, a, b, minHtlc = 2 msat), + makeEdge(10L, b, a, minHtlc = 2 msat, inboundFeeBase_opt = Some(-5000 msat), inboundFeeProportionalMillionth_opt = Some(-60_000)), + + makeEdge(11L, b, c, 1000 msat, 50_000, minHtlc = 2 msat), + makeEdge(11L, c, b, minHtlc = 2 msat, inboundFeeBase_opt = Some(5000 msat), inboundFeeProportionalMillionth_opt = Some(0)), + + makeEdge(12L, c, d, 0 msat, 50_000, minHtlc = 2 msat), + makeEdge(12L, d, c, minHtlc = 2 msat, inboundFeeBase_opt = Some(-8000 msat), inboundFeeProportionalMillionth_opt = Some(-50_000)), + + makeEdge(13L, d, e, 9000 msat, 100_000, minHtlc = 2 msat), + makeEdge(13L, e, d, minHtlc = 2 msat, inboundFeeBase_opt = Some(2000 msat), inboundFeeProportionalMillionth_opt = Some(80_000)), + )) + + val Success(route :: Nil) = findRoute(g, a, e, 100_000 msat, 100_000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000)) + + assert(route.channelFee(false) == 15_302.msat) + + val recipient = ClearRecipient(e, Features.empty, 100_000 msat, CltvExpiry(400018), paymentSecret) + val Right(payment) = buildOutgoingPayment(TestConstants.emptyOrigin, paymentHash, route, recipient, 1.0) + + assert(payment.outgoingChannel == ShortChannelId(10L)) + assert(payment.cmd.amount == 115_302.msat) + + val packet_b = payment.cmd.onion + + val add_b = UpdateAddHtlc(randomBytes32(), 0, 100_000 msat, paymentHash, CltvExpiry(400018), packet_b, None, 1.0, None) + val Right(relay_b@ChannelRelayPacket(_, payload_b, packet_c)) = decrypt(add_b, priv_b, Features.empty) + assert(payload_b.outgoing.contains(ShortChannelId(11L))) + assert(relay_b.amountToForward == 115_302.msat) + assert(relay_b.relayFeeMsat == -15_302.msat) + + val add_c = UpdateAddHtlc(randomBytes32(), 1, 100_000 msat, paymentHash, CltvExpiry(400018), packet_c, None, 1.0, None) + val Right(relay_c@ChannelRelayPacket(_, payload_c, packet_d)) = decrypt(add_c, priv_c, Features.empty) + assert(payload_c.outgoing.contains(ShortChannelId(12L))) + assert(relay_c.amountToForward == 105_050.msat) + assert(relay_c.relayFeeMsat == -5050.msat) + + val add_d = UpdateAddHtlc(randomBytes32(), 2, 100_000 msat, paymentHash, CltvExpiry(400018), packet_d, None, 1.0, None) + val Right(relay_d@ChannelRelayPacket(_, payload_d, packet_e)) = decrypt(add_d, priv_d, Features.empty) + assert(payload_d.outgoing.contains(ShortChannelId(13L))) + assert(relay_d.amountToForward == 100_000.msat) + assert(relay_d.relayFeeMsat == 0.msat) + + val add_e = UpdateAddHtlc(randomBytes32(), 2, 100_000 msat, paymentHash, CltvExpiry(400018), packet_e, None, 1.0, None) + val Right(FinalPacket(_, payload_e)) = decrypt(add_e, priv_e, Features.empty) + assert(payload_e.isInstanceOf[FinalPayload.Standard]) + assert(payload_e.amount == 100_000.msat) + assert(payload_e.totalAmount == 100_000.msat) + } + + test("test findRoute with Blip18 disabled") { + // extracted from the LND code base + val g = DirectedGraph(Seq( + makeEdge(10L, a, b, minHtlc = 2 msat), + makeEdge(10L, b, a, minHtlc = 2 msat, inboundFeeBase_opt = Some(-5000 msat), inboundFeeProportionalMillionth_opt = Some(-60_000)), + + makeEdge(11L, b, c, 1000 msat, 50_000, minHtlc = 2 msat), + makeEdge(11L, c, b, minHtlc = 2 msat, inboundFeeBase_opt = Some(5000 msat), inboundFeeProportionalMillionth_opt = Some(0)), + + makeEdge(12L, c, d, 0 msat, 50_000, minHtlc = 2 msat), + makeEdge(12L, d, c, minHtlc = 2 msat, inboundFeeBase_opt = Some(-8000 msat), inboundFeeProportionalMillionth_opt = Some(-50_000)), + + makeEdge(13L, d, e, 9000 msat, 100_000, minHtlc = 2 msat), + makeEdge(13L, e, d, minHtlc = 2 msat, inboundFeeBase_opt = Some(2000 msat), inboundFeeProportionalMillionth_opt = Some(80_000)), + )) + + val Success(route :: Nil) = findRoute(g, a, e, 100_000 msat, 100_000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(blip18InboundFees = false), currentBlockHeight = BlockHeight(400000)) + + assert(route.channelFee(false) == 32_197.msat) + + val recipient = ClearRecipient(e, Features.empty, 100_000 msat, CltvExpiry(400018), paymentSecret) + val Right(payment) = buildOutgoingPayment(TestConstants.emptyOrigin, paymentHash, route, recipient, 1.0) + + assert(payment.outgoingChannel == ShortChannelId(10L)) + assert(payment.cmd.amount == 132_197.msat) + + val packet_b = payment.cmd.onion + + val add_b = UpdateAddHtlc(randomBytes32(), 0, 100_000 msat, paymentHash, CltvExpiry(400018), packet_b, None, 1.0, None) + val Right(relay_b@ChannelRelayPacket(_, payload_b, packet_c)) = decrypt(add_b, priv_b, Features.empty) + assert(payload_b.outgoing.contains(ShortChannelId(11L))) + assert(relay_b.amountToForward == 124_950.msat) + assert(relay_b.relayFeeMsat == -24_950.msat) + + val add_c = UpdateAddHtlc(randomBytes32(), 1, 100_000 msat, paymentHash, CltvExpiry(400018), packet_c, None, 1.0, None) + val Right(relay_c@ChannelRelayPacket(_, payload_c, packet_d)) = decrypt(add_c, priv_c, Features.empty) + assert(payload_c.outgoing.contains(ShortChannelId(12L))) + assert(relay_c.amountToForward == 119_000.msat) + assert(relay_c.relayFeeMsat == -19000.msat) + + val add_d = UpdateAddHtlc(randomBytes32(), 2, 100_000 msat, paymentHash, CltvExpiry(400018), packet_d, None, 1.0, None) + val Right(relay_d@ChannelRelayPacket(_, payload_d, packet_e)) = decrypt(add_d, priv_d, Features.empty) + assert(payload_d.outgoing.contains(ShortChannelId(13L))) + assert(relay_d.amountToForward == 100_000.msat) + assert(relay_d.relayFeeMsat == 0.msat) + + val add_e = UpdateAddHtlc(randomBytes32(), 2, 100_000 msat, paymentHash, CltvExpiry(400018), packet_e, None, 1.0, None) + val Right(FinalPacket(_, payload_e)) = decrypt(add_e, priv_e, Features.empty) + assert(payload_e.isInstanceOf[FinalPayload.Standard]) + assert(payload_e.amount == 100_000.msat) + assert(payload_e.totalAmount == 100_000.msat) + } + + test("test findMultiPartRoute with Blip18 enabled") { + // extracted from the LND code base + val g = DirectedGraph(Seq( + makeEdge(10L, a, b, minHtlc = 2 msat), + makeEdge(10L, b, a, minHtlc = 2 msat, inboundFeeBase_opt = Some(-5000 msat), inboundFeeProportionalMillionth_opt = Some(-60_000)), + + makeEdge(11L, b, c, 1000 msat, 50_000, minHtlc = 2 msat), + makeEdge(11L, c, b, minHtlc = 2 msat, inboundFeeBase_opt = Some(5000 msat), inboundFeeProportionalMillionth_opt = Some(0)), + + makeEdge(12L, c, d, 0 msat, 50_000, minHtlc = 2 msat), + makeEdge(12L, d, c, minHtlc = 2 msat, inboundFeeBase_opt = Some(-8000 msat), inboundFeeProportionalMillionth_opt = Some(-50_000)), + + makeEdge(13L, d, e, 9000 msat, 100_000, minHtlc = 2 msat), + makeEdge(13L, e, d, minHtlc = 2 msat, inboundFeeBase_opt = Some(2000 msat), inboundFeeProportionalMillionth_opt = Some(80_000)), + )) + + val Success(route :: Nil) = findMultiPartRoute(g, a, e, 100_000 msat, 100_000 msat, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000)) + + assert(route.channelFee(false) == 15_302.msat) + + val recipient = ClearRecipient(e, Features.empty, 100_000 msat, CltvExpiry(400018), paymentSecret) + val Right(payment) = buildOutgoingPayment(TestConstants.emptyOrigin, paymentHash, route, recipient, 1.0) + + assert(payment.outgoingChannel == ShortChannelId(10L)) + assert(payment.cmd.amount == 115_302.msat) + + val packet_b = payment.cmd.onion + + val add_b = UpdateAddHtlc(randomBytes32(), 0, 100_000 msat, paymentHash, CltvExpiry(400018), packet_b, None, 1.0, None) + val Right(relay_b@ChannelRelayPacket(_, payload_b, packet_c)) = decrypt(add_b, priv_b, Features.empty) + assert(payload_b.outgoing.contains(ShortChannelId(11L))) + assert(relay_b.amountToForward == 115_302.msat) + assert(relay_b.relayFeeMsat == -15_302.msat) + + val add_c = UpdateAddHtlc(randomBytes32(), 1, 100_000 msat, paymentHash, CltvExpiry(400018), packet_c, None, 1.0, None) + val Right(relay_c@ChannelRelayPacket(_, payload_c, packet_d)) = decrypt(add_c, priv_c, Features.empty) + assert(payload_c.outgoing.contains(ShortChannelId(12L))) + assert(relay_c.amountToForward == 105_050.msat) + assert(relay_c.relayFeeMsat == -5050.msat) + + val add_d = UpdateAddHtlc(randomBytes32(), 2, 100_000 msat, paymentHash, CltvExpiry(400018), packet_d, None, 1.0, None) + val Right(relay_d@ChannelRelayPacket(_, payload_d, packet_e)) = decrypt(add_d, priv_d, Features.empty) + assert(payload_d.outgoing.contains(ShortChannelId(13L))) + assert(relay_d.amountToForward == 100_000.msat) + assert(relay_d.relayFeeMsat == 0.msat) + + val add_e = UpdateAddHtlc(randomBytes32(), 2, 100_000 msat, paymentHash, CltvExpiry(400018), packet_e, None, 1.0, None) + val Right(FinalPacket(_, payload_e)) = decrypt(add_e, priv_e, Features.empty) + assert(payload_e.isInstanceOf[FinalPayload.Standard]) + assert(payload_e.amount == 100_000.msat) + assert(payload_e.totalAmount == 100_000.msat) + } + + test("test findMultiPartRoute with Blip18 disabled") { + // extracted from the LND code base + val g = DirectedGraph(Seq( + makeEdge(10L, a, b, minHtlc = 2 msat), + makeEdge(10L, b, a, minHtlc = 2 msat, inboundFeeBase_opt = Some(-5000 msat), inboundFeeProportionalMillionth_opt = Some(-60_000)), + + makeEdge(11L, b, c, 1000 msat, 50_000, minHtlc = 2 msat), + makeEdge(11L, c, b, minHtlc = 2 msat, inboundFeeBase_opt = Some(5000 msat), inboundFeeProportionalMillionth_opt = Some(0)), + + makeEdge(12L, c, d, 0 msat, 50_000, minHtlc = 2 msat), + makeEdge(12L, d, c, minHtlc = 2 msat, inboundFeeBase_opt = Some(-8000 msat), inboundFeeProportionalMillionth_opt = Some(-50_000)), + + makeEdge(13L, d, e, 9000 msat, 100_000, minHtlc = 2 msat), + makeEdge(13L, e, d, minHtlc = 2 msat, inboundFeeBase_opt = Some(2000 msat), inboundFeeProportionalMillionth_opt = Some(80_000)), + )) + + val Success(route :: Nil) = findMultiPartRoute(g, a, e, 100_000 msat, 100_000 msat, routeParams = DEFAULT_ROUTE_PARAMS.copy(blip18InboundFees = false), currentBlockHeight = BlockHeight(400000)) + + assert(route.channelFee(false) == 32_197.msat) + + val recipient = ClearRecipient(e, Features.empty, 100_000 msat, CltvExpiry(400018), paymentSecret) + val Right(payment) = buildOutgoingPayment(TestConstants.emptyOrigin, paymentHash, route, recipient, 1.0) + + assert(payment.outgoingChannel == ShortChannelId(10L)) + assert(payment.cmd.amount == 132_197.msat) + + val packet_b = payment.cmd.onion + + val add_b = UpdateAddHtlc(randomBytes32(), 0, 100_000 msat, paymentHash, CltvExpiry(400018), packet_b, None, 1.0, None) + val Right(relay_b@ChannelRelayPacket(_, payload_b, packet_c)) = decrypt(add_b, priv_b, Features.empty) + assert(payload_b.outgoing.contains(ShortChannelId(11L))) + assert(relay_b.amountToForward == 124_950.msat) + assert(relay_b.relayFeeMsat == -24_950.msat) + + val add_c = UpdateAddHtlc(randomBytes32(), 1, 100_000 msat, paymentHash, CltvExpiry(400018), packet_c, None, 1.0, None) + val Right(relay_c@ChannelRelayPacket(_, payload_c, packet_d)) = decrypt(add_c, priv_c, Features.empty) + assert(payload_c.outgoing.contains(ShortChannelId(12L))) + assert(relay_c.amountToForward == 119_000.msat) + assert(relay_c.relayFeeMsat == -19000.msat) + + val add_d = UpdateAddHtlc(randomBytes32(), 2, 100_000 msat, paymentHash, CltvExpiry(400018), packet_d, None, 1.0, None) + val Right(relay_d@ChannelRelayPacket(_, payload_d, packet_e)) = decrypt(add_d, priv_d, Features.empty) + assert(payload_d.outgoing.contains(ShortChannelId(13L))) + assert(relay_d.amountToForward == 100_000.msat) + assert(relay_d.relayFeeMsat == 0.msat) + + val add_e = UpdateAddHtlc(randomBytes32(), 2, 100_000 msat, paymentHash, CltvExpiry(400018), packet_e, None, 1.0, None) + val Right(FinalPacket(_, payload_e)) = decrypt(add_e, priv_e, Features.empty) + assert(payload_e.isInstanceOf[FinalPayload.Standard]) + assert(payload_e.amount == 100_000.msat) + assert(payload_e.totalAmount == 100_000.msat) + } + + test("calculate Blip18 simple route with a positive inbound fees channel") { + // channels with positive (greater than 0) inbound fees should be automatically excluded from path finding + val ROUTE_PARAMS = DEFAULT_ROUTE_PARAMS.copy(excludePositiveInboundFees = true) + + { + val g = DirectedGraph(List( + makeEdge(1L, a, b, 1 msat, 10, cltvDelta = CltvExpiryDelta(1), balance_opt = Some(DEFAULT_AMOUNT_MSAT * 2)), + makeEdge(2L, b, c, 2 msat, 20, cltvDelta = CltvExpiryDelta(1)), + makeEdge(2L, c, b, 2 msat, 20, cltvDelta = CltvExpiryDelta(1), inboundFeeBase_opt = Some(1 msat), inboundFeeProportionalMillionth_opt = Some(10)), + makeEdge(3L, c, d, 1 msat, 10, cltvDelta = CltvExpiryDelta(1)), + makeEdge(4L, d, e, 1 msat, 10, cltvDelta = CltvExpiryDelta(1)) + )) + + val res = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000)) + assert(res == Failure(RouteNotFound)) + } + { + val g = DirectedGraph(List( + makeEdge(1L, a, b, 1 msat, 10, cltvDelta = CltvExpiryDelta(1), balance_opt = Some(DEFAULT_AMOUNT_MSAT * 2)), + makeEdge(2L, b, c, 2 msat, 20, cltvDelta = CltvExpiryDelta(1)), + makeEdge(2L, c, b, 2 msat, 20, cltvDelta = CltvExpiryDelta(1), inboundFeeBase_opt = Some(1 msat), inboundFeeProportionalMillionth_opt = Some(0)), + makeEdge(3L, c, d, 1 msat, 10, cltvDelta = CltvExpiryDelta(1)), + makeEdge(4L, d, e, 1 msat, 10, cltvDelta = CltvExpiryDelta(1)) + )) + + val res = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000)) + assert(res == Failure(RouteNotFound)) + } + { + val g = DirectedGraph(List( + makeEdge(1L, a, b, 1 msat, 10, cltvDelta = CltvExpiryDelta(1), balance_opt = Some(DEFAULT_AMOUNT_MSAT * 2)), + makeEdge(2L, b, c, 2 msat, 20, cltvDelta = CltvExpiryDelta(1)), + makeEdge(2L, c, b, 2 msat, 20, cltvDelta = CltvExpiryDelta(1), inboundFeeBase_opt = Some(0 msat), inboundFeeProportionalMillionth_opt = Some(10)), + makeEdge(3L, c, d, 1 msat, 10, cltvDelta = CltvExpiryDelta(1)), + makeEdge(4L, d, e, 1 msat, 10, cltvDelta = CltvExpiryDelta(1)) + )) + + val res = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000)) + assert(res == Failure(RouteNotFound)) + } + { + val g = DirectedGraph(List( + makeEdge(1L, a, b, 1 msat, 10, cltvDelta = CltvExpiryDelta(1), balance_opt = Some(DEFAULT_AMOUNT_MSAT * 2)), + makeEdge(2L, b, c, 2 msat, 20, cltvDelta = CltvExpiryDelta(1)), + makeEdge(2L, c, b, 2 msat, 20, cltvDelta = CltvExpiryDelta(1), inboundFeeBase_opt = Some(1 msat), inboundFeeProportionalMillionth_opt = Some(-10)), + makeEdge(3L, c, d, 1 msat, 10, cltvDelta = CltvExpiryDelta(1)), + makeEdge(4L, d, e, 1 msat, 10, cltvDelta = CltvExpiryDelta(1)) + )) + + val res = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000)) + assert(res == Failure(RouteNotFound)) + } + { + val g = DirectedGraph(List( + makeEdge(1L, a, b, 1 msat, 10, cltvDelta = CltvExpiryDelta(1), balance_opt = Some(DEFAULT_AMOUNT_MSAT * 2)), + makeEdge(2L, b, c, 2 msat, 20, cltvDelta = CltvExpiryDelta(1)), + makeEdge(2L, c, b, 2 msat, 20, cltvDelta = CltvExpiryDelta(1), inboundFeeBase_opt = Some(-1 msat), inboundFeeProportionalMillionth_opt = Some(10)), + makeEdge(3L, c, d, 1 msat, 10, cltvDelta = CltvExpiryDelta(1)), + makeEdge(4L, d, e, 1 msat, 10, cltvDelta = CltvExpiryDelta(1)) + )) + + val res = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000)) + assert(res == Failure(RouteNotFound)) + } + } + +} + +object Blip18RouteCalculationSpec { + + val DEFAULT_AMOUNT_MSAT = 10_000_000 msat + val DEFAULT_MAX_FEE = 100_000 msat + val DEFAULT_EXPIRY = CltvExpiry(TestConstants.defaultBlockHeight) + val DEFAULT_CAPACITY = 100_000 sat + + val NO_WEIGHT_RATIOS: WeightRatios = WeightRatios(1, 0, 0, 0, RelayFees(0 msat, 0)) + val DEFAULT_ROUTE_PARAMS = PathFindingConf( + randomize = false, + boundaries = SearchBoundaries(21000 msat, 0.03, 6, CltvExpiryDelta(2016)), + Left(NO_WEIGHT_RATIOS), + MultiPartParams(1000 msat, 10), + experimentName = "my-test-experiment", + experimentPercentage = 100, + blip18InboundFees = true).getDefaultRouteParams + + val DUMMY_SIG = Transactions.PlaceHolderSig + + def makeChannel(shortChannelId: Long, nodeIdA: PublicKey, nodeIdB: PublicKey): ChannelAnnouncement = { + val (nodeId1, nodeId2) = if (Announcements.isNode1(nodeIdA, nodeIdB)) (nodeIdA, nodeIdB) else (nodeIdB, nodeIdA) + ChannelAnnouncement(DUMMY_SIG, DUMMY_SIG, DUMMY_SIG, DUMMY_SIG, Features.empty, Block.RegtestGenesisBlock.hash, RealShortChannelId(shortChannelId), nodeId1, nodeId2, randomKey().publicKey, randomKey().publicKey) + } + + def makeEdge(shortChannelId: Long, + nodeId1: PublicKey, + nodeId2: PublicKey, + feeBase: MilliSatoshi = 0 msat, + feeProportionalMillionth: Int = 0, + minHtlc: MilliSatoshi = DEFAULT_AMOUNT_MSAT, + maxHtlc: Option[MilliSatoshi] = None, + cltvDelta: CltvExpiryDelta = CltvExpiryDelta(0), + capacity: Satoshi = DEFAULT_CAPACITY, + balance_opt: Option[MilliSatoshi] = None, + inboundFeeBase_opt: Option[MilliSatoshi] = None, + inboundFeeProportionalMillionth_opt: Option[Int] = None): GraphEdge = { + val update = makeUpdateShort(ShortChannelId(shortChannelId), nodeId1, nodeId2, feeBase, feeProportionalMillionth, minHtlc, maxHtlc, cltvDelta, inboundFeeBase_opt = inboundFeeBase_opt, inboundFeeProportionalMillionth_opt = inboundFeeProportionalMillionth_opt) + 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, inboundFeeBase_opt: Option[MilliSatoshi] = None, inboundFeeProportionalMillionth_opt: Option[Int] = None): ChannelUpdate = { + val tlvStream: TlvStream[ChannelUpdateTlv] = if (inboundFeeBase_opt.isDefined && inboundFeeProportionalMillionth_opt.isDefined) { + TlvStream(ChannelUpdateTlv.Blip18InboundFee(inboundFeeBase_opt.get.toLong.toInt, inboundFeeProportionalMillionth_opt.get)) + } else { + TlvStream.empty + } + ChannelUpdate( + signature = DUMMY_SIG, + chainHash = Block.RegtestGenesisBlock.hash, + shortChannelId = shortChannelId, + timestamp = timestamp, + messageFlags = ChannelUpdate.MessageFlags(dontForward = false), + channelFlags = ChannelUpdate.ChannelFlags(isEnabled = true, isNode1 = Announcements.isNode1(nodeId1, nodeId2)), + cltvExpiryDelta = cltvDelta, + htlcMinimumMsat = minHtlc, + feeBaseMsat = feeBase, + feeProportionalMillionths = feeProportionalMillionth, + htlcMaximumMsat = maxHtlc.getOrElse(500_000_000 msat), + tlvStream = tlvStream + ) + } +} 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 edec7c97a9..73284bf52c 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 @@ -552,7 +552,7 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution { ) val publicChannels = channels.map { case (shortChannelId, announcement) => - val HopRelayParams.FromAnnouncement(update) = edges.find(_.desc.shortChannelId == shortChannelId).get.params + val HopRelayParams.FromAnnouncement(update, _) = edges.find(_.desc.shortChannelId == shortChannelId).get.params val (update_1_opt, update_2_opt) = if (update.channelFlags.isNode1) (Some(update), None) else (None, Some(update)) val pc = PublicChannel(announcement, TxId(ByteVector32.Zeroes), Satoshi(1000), update_1_opt, update_2_opt, None) (shortChannelId, pc) diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Payment.scala b/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Payment.scala index e0528dcc4b..ec1f618a51 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Payment.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Payment.scala @@ -56,11 +56,11 @@ trait Payment { val sendToRoute: Route = postRequest("sendtoroute") { implicit t => withRoute { hops => formFields(amountMsatFormParam, "recipientAmountMsat".as[MilliSatoshi].?, invoiceFormParam, "externalId".?, "parentId".as[UUID].?, - "trampolineSecret".as[ByteVector32].?, "trampolineFeesMsat".as[MilliSatoshi].?, "trampolineCltvExpiry".as[Int].?, maxFeeMsatFormParam.?) { - (amountMsat, recipientAmountMsat_opt, invoice, externalId_opt, parentId_opt, trampolineSecret_opt, trampolineFeesMsat_opt, trampolineCltvExpiry_opt, maxFee_opt) => { + "trampolineSecret".as[ByteVector32].?, "trampolineFeesMsat".as[MilliSatoshi].?, "trampolineCltvExpiry".as[Int].?, maxFeeMsatFormParam.?, "blip18InboundFees".as[Boolean].?, "excludePositiveInboundFees".as[Boolean].?) { + (amountMsat, recipientAmountMsat_opt, invoice, externalId_opt, parentId_opt, trampolineSecret_opt, trampolineFeesMsat_opt, trampolineCltvExpiry_opt, maxFee_opt, blip18InboundFees_opt, excludePositiveInboundFees_opt) => { val route = hops match { - case Left(shortChannelIds) => PredefinedChannelRoute(amountMsat, invoice.nodeId, shortChannelIds, maxFee_opt) - case Right(nodeIds) => PredefinedNodeRoute(amountMsat, nodeIds, maxFee_opt) + case Left(shortChannelIds) => PredefinedChannelRoute(amountMsat, invoice.nodeId, shortChannelIds, maxFee_opt, blip18InboundFees_opt.getOrElse(false), excludePositiveInboundFees_opt.getOrElse(false)) + case Right(nodeIds) => PredefinedNodeRoute(amountMsat, nodeIds, maxFee_opt, blip18InboundFees_opt.getOrElse(false), excludePositiveInboundFees_opt.getOrElse(false)) } complete(eclairApi.sendToRoute( recipientAmountMsat_opt, externalId_opt, parentId_opt, invoice, route, trampolineSecret_opt, trampolineFeesMsat_opt, trampolineCltvExpiry_opt.map(CltvExpiryDelta)) From 5f9e17d44ea937f78f937b72da41d0610270b03b Mon Sep 17 00:00:00 2001 From: rorp Date: Fri, 25 Oct 2024 14:08:44 -0400 Subject: [PATCH 2/2] Fix bugs --- .../main/scala/fr/acinq/eclair/router/Graph.scala | 10 ++++++++-- .../fr/acinq/eclair/router/RouteCalculation.scala | 12 +++++------- .../main/scala/fr/acinq/eclair/router/Router.scala | 4 +--- .../eclair/wire/protocol/LightningMessageTypes.scala | 2 +- .../eclair/router/Blip18RouteCalculationSpec.scala | 10 ++++++++++ 5 files changed, 25 insertions(+), 13 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 461f33519d..842c791fbc 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 @@ -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} @@ -254,7 +255,7 @@ object Graph { current.weight.amount >= edge.params.htlcMinimum && !ignoredEdges.contains(edge.desc) && !ignoredVertices.contains(neighbor) && - (!excludePositiveInboundFees || g.getBackEdge(edge).forall(e => e.params.inboundFees_opt.forall(i => i.feeBase.toLong <= 0 && i.feeProportionalMillionths <= 0)))) { + (!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) @@ -598,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 { 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 0ffa4f301e..ec26867988 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 @@ -22,14 +22,12 @@ 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} import fr.acinq.eclair.router.Graph.{InfiniteLoop, MessagePath, NegativeProbability, RichWeight} import fr.acinq.eclair.router.Monitoring.{Metrics, Tags} import fr.acinq.eclair.router.Router._ -import fr.acinq.eclair.wire.protocol.ChannelUpdateTlv.Blip18InboundFee import kamon.tag.TagSet import scala.annotation.tailrec @@ -350,13 +348,13 @@ object RouteCalculation { val hops = routeHops.reverse val updatedHops = routeHops.head :: hops.zip(hops.tail).foldLeft(List.empty[ChannelHop]) { (hops, x) => val (curr, prev) = x - val maybeEdge = g.getBackEdge(ChannelDesc(prev.shortChannelId, prev.nodeId, prev.nextNodeId)) + val backEdge_opt = g.getBackEdge(ChannelDesc(prev.shortChannelId, prev.nodeId, prev.nextNodeId)) val hop = curr.copy(params = curr.params match { case hopParams: HopRelayParams.FromAnnouncement => - maybeEdge match { - case Some(backEdge) => hopParams.copy(updatedInboundFees_opt = backEdge.params.inboundFees_opt) - case _ => hopParams - } + backEdge_opt + .flatMap(_.getChannelUpdate) + .map(u => hopParams.copy(inboundFees_opt = u.blip18InboundFees_opt)) + .getOrElse(hopParams) case hopParams => hopParams }) 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 94b48b14ac..6d3a66f2d3 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 @@ -491,13 +491,11 @@ object Router { object HopRelayParams { /** We learnt about this channel from a channel_update. */ - case class FromAnnouncement(channelUpdate: ChannelUpdate, updatedInboundFees_opt: Option[Relayer.InboundFees] = None) extends HopRelayParams { + case class FromAnnouncement(channelUpdate: ChannelUpdate, inboundFees_opt: Option[Relayer.InboundFees] = None) extends HopRelayParams { override val cltvExpiryDelta = channelUpdate.cltvExpiryDelta override val relayFees = channelUpdate.relayFees - override val inboundFees_opt = updatedInboundFees_opt orElse channelUpdate.inboundFees_opt override val htlcMinimum = channelUpdate.htlcMinimumMsat override val htlcMaximum_opt = Some(channelUpdate.htlcMaximumMsat) - } /** We learnt about this hop from hints in an invoice. */ diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala index e884ff5151..b8e0005e38 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala @@ -544,7 +544,7 @@ case class ChannelUpdate(signature: ByteVector64, def relayFees: Relayer.RelayFees = Relayer.RelayFees(feeBase = feeBaseMsat, feeProportionalMillionths = feeProportionalMillionths) - def inboundFees_opt: Option[Relayer.InboundFees] = + def blip18InboundFees_opt: Option[Relayer.InboundFees] = tlvStream.get[ChannelUpdateTlv.Blip18InboundFee].map(blip18 => Relayer.InboundFees(blip18.feeBase, blip18.feeProportionalMillionths)) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/router/Blip18RouteCalculationSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/router/Blip18RouteCalculationSpec.scala index 68c7e9a41c..d621d0bbb8 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/router/Blip18RouteCalculationSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/router/Blip18RouteCalculationSpec.scala @@ -27,6 +27,16 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution val (priv_a, priv_b, priv_c, priv_d, priv_e, priv_f) = (randomKey(), randomKey(), randomKey(), randomKey(), randomKey(), randomKey()) val (a, b, c, d, e, f) = (priv_a.publicKey, priv_b.publicKey, priv_c.publicKey, priv_d.publicKey, priv_e.publicKey, priv_f.publicKey) + test("find a direct route") { + val g = DirectedGraph(Seq( + makeEdge(10L, a, b, minHtlc = 2 msat, feeBase = 0 msat, feeProportionalMillionth = 120, inboundFeeBase_opt = Some(0.msat), inboundFeeProportionalMillionth_opt = Some(-71)), + )) + + val Success(route :: Nil) = findRoute(g, a, b, 10_000_000 msat, 10_000_000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(includeLocalChannelCost = true), currentBlockHeight = BlockHeight(400000)) + + assert(route.channelFee(true) == 1200.msat) + } + test("test findRoute with Blip18 enabled") { // extracted from the LND code base val g = DirectedGraph(Seq(