From 2e854e36ec7bc8b37dda900896af4c56871f62d2 Mon Sep 17 00:00:00 2001 From: rorp Date: Sun, 20 Oct 2024 11:28:34 -0700 Subject: [PATCH 01/11] 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 02/11] 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( From 799e5fd3b819834ba95b2c49edc16dbdf99ebb30 Mon Sep 17 00:00:00 2001 From: rorp Date: Sun, 8 Jun 2025 21:04:45 -0700 Subject: [PATCH 03/11] bLIP-18 inbound routing fees --- .gitignore | 2 + docs/release-notes/eclair-vnext.md | 84 +++++++++++++++++ .../main/scala/fr/acinq/eclair/Eclair.scala | 22 ++++- .../fr/acinq/eclair/channel/ChannelData.scala | 2 +- .../fr/acinq/eclair/channel/Helpers.scala | 6 +- .../fr/acinq/eclair/channel/fsm/Channel.scala | 20 ++-- .../channel/fsm/CommonFundingHandlers.scala | 5 +- .../scala/fr/acinq/eclair/db/Databases.scala | 8 +- .../fr/acinq/eclair/db/DualDatabases.scala | 18 +++- .../fr/acinq/eclair/db/InboundFeesDb.scala | 13 +++ .../acinq/eclair/db/pg/PgInboundFeesDb.scala | 70 ++++++++++++++ .../db/sqlite/SqliteInboundFeesDb.scala | 61 ++++++++++++ .../main/scala/fr/acinq/eclair/package.scala | 15 ++- .../eclair/payment/relay/ChannelRelay.scala | 94 ++++++++++++------- .../acinq/eclair/payment/relay/Relayer.scala | 9 ++ .../payment/send/BlindedPathsResolver.scala | 2 +- .../acinq/eclair/router/Announcements.scala | 15 +-- .../eclair/router/RouteCalculation.scala | 6 +- .../scala/fr/acinq/eclair/router/Router.scala | 7 +- .../eclair/wire/protocol/RoutingTlv.scala | 5 + .../fr/acinq/eclair/EclairImplSpec.scala | 13 ++- .../scala/fr/acinq/eclair/TestDatabases.scala | 5 +- .../fr/acinq/eclair/db/DbMigrationSpec.scala | 3 +- .../acinq/eclair/db/InboundFeesDbSpec.scala | 45 +++++++++ .../payment/relay/ChannelRelayerSpec.scala | 21 ++++- .../eclair/router/AnnouncementsSpec.scala | 4 + .../fr/acinq/eclair/api/handlers/Fees.scala | 16 +++- 27 files changed, 484 insertions(+), 87 deletions(-) create mode 100644 docs/release-notes/eclair-vnext.md create mode 100644 eclair-core/src/main/scala/fr/acinq/eclair/db/InboundFeesDb.scala create mode 100644 eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgInboundFeesDb.scala create mode 100644 eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteInboundFeesDb.scala create mode 100644 eclair-core/src/test/scala/fr/acinq/eclair/db/InboundFeesDbSpec.scala diff --git a/.gitignore b/.gitignore index f72ed6206a..a25470c558 100644 --- a/.gitignore +++ b/.gitignore @@ -26,5 +26,7 @@ project/target DeleteMe*.* *~ jdbcUrlFile_*.tmp +.metals/ +.vscode/ .DS_Store diff --git a/docs/release-notes/eclair-vnext.md b/docs/release-notes/eclair-vnext.md new file mode 100644 index 0000000000..f270f90792 --- /dev/null +++ b/docs/release-notes/eclair-vnext.md @@ -0,0 +1,84 @@ +# Eclair vnext + + + +## Major changes + + + +### bLIP-18 Inbound Routing Fees + +Eclair now supports [bLIP-18 inbound routing fees](https://github.com/lightning/blips/pull/18) which proposes an optional +TLV for channel updates that allows node operators to set (and optionally advertise) inbound routing fee discounts, enabling +more flexible fee policies and incentivizing desired incoming traffic. + +#### Configuration + +| Configuration Parameter | Default Value | Description | +|----------------------------------------------------------------------------------|---------------|----------------------------------------------------------------------------------------------------------------------------------------------------------| +| `eclair.router.path-finding.default.blip18-inbound-fees` | `false` | enables support for bLIP-18 inbound routing fees | +| `eclair.router.path-finding.default.exclude-channels-with-positive-inbound-fees` | `false` | enables exclusion of channels with positive inbound fees from path finding, helping to prevent `FeeInsufficient` errors and ensure more reliable routing | + +The routing logic considers inbound fees during route selection if enabled. New logic is added to exclude channels with +positive inbound fees from route finding when configured. The relay and route calculation logic now computes total fees +as the sum of the regular (outbound) and inbound fees when applicable. + +The wire protocol is updated to include the new TLV (0x55555) type for bLIP-18 inbound fees in ChannelUpdate messages. +Code that (de)serializes channel updates now handles these new fields. + +New database tables and migration updates for storing inbound fee information per peer. + +### API changes + + + +- `updaterelayfee` now accepts optional `--inboundFeeBaseMsat` and `--inboundFeeProportionalMillionths` parameters. If omitted, existing inbound fees will be preserved. + +### Miscellaneous improvements and bug fixes + + + +## Verifying signatures + +You will need `gpg` and our release signing key E04E48E72C205463. Note that you can get it: + +- from our website: https://acinq.co/pgp/drouinf2.asc +- from github user @sstone, a committer on eclair: https://api.github.com/users/sstone/gpg_keys + +To import our signing key: + +```sh +$ gpg --import drouinf2.asc +``` + +To verify the release file checksums and signatures: + +```sh +$ gpg -d SHA256SUMS.asc > SHA256SUMS.stripped +$ sha256sum -c SHA256SUMS.stripped +``` + +## Building + +Eclair builds are deterministic. To reproduce our builds, please use the following environment (*): + +- Ubuntu 24.04.1 +- Adoptium OpenJDK 21.0.6 + +Use the following command to generate the eclair-node package: + +```sh +./mvnw clean install -DskipTests +``` + +That should generate `eclair-node/target/eclair-node--XXXXXXX-bin.zip` with sha256 checksums that match the one we provide and sign in `SHA256SUMS.asc` + +(*) You may be able to build the exact same artefacts with other operating systems or versions of JDK 21, we have not tried everything. + +## Upgrading + +This release is fully compatible with previous eclair versions. You don't need to close your channels, just stop eclair, upgrade and restart. + +## Changelog + + diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala index da2d0c8e60..ea39a7269e 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala @@ -44,7 +44,7 @@ import fr.acinq.eclair.message.{OnionMessages, Postman} import fr.acinq.eclair.payment._ import fr.acinq.eclair.payment.offer.{OfferCreator, OfferManager} import fr.acinq.eclair.payment.receive.MultiPartHandler.ReceiveStandardPayment -import fr.acinq.eclair.payment.relay.Relayer.{ChannelBalance, GetOutgoingChannels, OutgoingChannels, RelayFees} +import fr.acinq.eclair.payment.relay.Relayer.{ChannelBalance, GetOutgoingChannels, InboundFees, OutgoingChannels, RelayFees} import fr.acinq.eclair.payment.send.PaymentInitiator._ import fr.acinq.eclair.payment.send.{ClearRecipient, OfferPayment, PaymentIdentifier} import fr.acinq.eclair.router.Router @@ -114,6 +114,8 @@ trait Eclair { def updateRelayFee(nodes: List[PublicKey], feeBase: MilliSatoshi, feeProportionalMillionths: Long)(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_UPDATE_RELAY_FEE]]]] + def updateRelayFee(nodes: List[PublicKey], feeBase: MilliSatoshi, feeProportionalMillionths: Long, inboundFeeBase_opt: Option[MilliSatoshi], inboundFeeProportional_opt: Option[Long])(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_UPDATE_RELAY_FEE]]]] + def channelsInfo(toRemoteNode_opt: Option[PublicKey])(implicit timeout: Timeout): Future[Iterable[RES_GET_CHANNEL_INFO]] def channelInfo(channel: ApiTypes.ChannelIdentifier)(implicit timeout: Timeout): Future[CommandResponse[CMD_GET_CHANNEL_INFO]] @@ -308,11 +310,21 @@ class EclairImpl(val appKit: Kit) extends Eclair with Logging with SpendFromChan sendToChannelsTyped(channels, cmdBuilder = CMD_BUMP_FORCE_CLOSE_FEE(_, confirmationTarget)) } - override def updateRelayFee(nodes: List[PublicKey], feeBaseMsat: MilliSatoshi, feeProportionalMillionths: Long)(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_UPDATE_RELAY_FEE]]]] = { - for (nodeId <- nodes) { - appKit.nodeParams.db.peers.addOrUpdateRelayFees(nodeId, RelayFees(feeBaseMsat, feeProportionalMillionths)) + override def updateRelayFee(nodes: List[PublicKey], feeBaseMsat: MilliSatoshi, feeProportionalMillionths: Long)(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_UPDATE_RELAY_FEE]]]] = + updateRelayFee(nodes, feeBaseMsat, feeProportionalMillionths, None, None) + + override def updateRelayFee(nodes: List[PublicKey], feeBaseMsat: MilliSatoshi, feeProportionalMillionths: Long, inboundFeeBase_opt: Option[MilliSatoshi], inboundFeeProportional_opt: Option[Long])(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_UPDATE_RELAY_FEE]]]] = { + if ((inboundFeeBase_opt.isDefined || inboundFeeProportional_opt.isDefined) && !appKit.nodeParams.routerConf.blip18InboundFees) { + Future.failed(new IllegalArgumentException("Cannot specify inbound fees when bLIP-18 support is disabled")) + } else { + for (nodeId <- nodes) { + appKit.nodeParams.db.peers.addOrUpdateRelayFees(nodeId, RelayFees(feeBaseMsat, feeProportionalMillionths)) + InboundFees.fromOptions(inboundFeeBase_opt, inboundFeeProportional_opt).foreach { inboundFees => + appKit.nodeParams.db.inboundFees.addOrUpdateInboundFees(nodeId, inboundFees) + } + } + sendToNodes(nodes, CMD_UPDATE_RELAY_FEE(ActorRef.noSender, feeBaseMsat, feeProportionalMillionths, inboundFeeBase_opt, inboundFeeProportional_opt)) } - sendToNodes(nodes, CMD_UPDATE_RELAY_FEE(ActorRef.noSender, feeBaseMsat, feeProportionalMillionths)) } override def peers()(implicit timeout: Timeout): Future[Iterable[PeerInfo]] = for { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala index b3b125597e..fdc204c61e 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala @@ -242,7 +242,7 @@ final case class CMD_SPLICE(replyTo: akka.actor.typed.ActorRef[CommandResponse[C val spliceOutputs: List[TxOut] = spliceOut_opt.toList.map(s => TxOut(s.amount, s.scriptPubKey)) } final case class CMD_BUMP_FUNDING_FEE(replyTo: akka.actor.typed.ActorRef[CommandResponse[ChannelFundingCommand]], targetFeerate: FeeratePerKw, fundingFeeBudget: Satoshi, lockTime: Long, requestFunding_opt: Option[LiquidityAds.RequestFunding]) extends ChannelFundingCommand -final case class CMD_UPDATE_RELAY_FEE(replyTo: ActorRef, feeBase: MilliSatoshi, feeProportionalMillionths: Long) extends HasReplyToCommand +final case class CMD_UPDATE_RELAY_FEE(replyTo: ActorRef, feeBase: MilliSatoshi, feeProportionalMillionths: Long, inboundFeeBase_opt: Option[MilliSatoshi] = None, inboundFeeProportionalMillionths_opt: Option[Long]= None) extends HasReplyToCommand final case class CMD_GET_CHANNEL_STATE(replyTo: ActorRef) extends HasReplyToCommand final case class CMD_GET_CHANNEL_DATA(replyTo: ActorRef) extends HasReplyToCommand final case class CMD_GET_CHANNEL_INFO(replyTo: akka.actor.typed.ActorRef[RES_GET_CHANNEL_INFO]) extends Command diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala index 7cdd1981f3..5c3c41e20e 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala @@ -29,7 +29,7 @@ import fr.acinq.eclair.channel.fsm.Channel.REFRESH_CHANNEL_UPDATE_INTERVAL import fr.acinq.eclair.crypto.keymanager.ChannelKeyManager import fr.acinq.eclair.crypto.{Generators, ShaChain} import fr.acinq.eclair.db.ChannelsDb -import fr.acinq.eclair.payment.relay.Relayer.RelayFees +import fr.acinq.eclair.payment.relay.Relayer.{InboundFees, RelayFees} import fr.acinq.eclair.router.Announcements import fr.acinq.eclair.transactions.DirectedHtlc._ import fr.acinq.eclair.transactions.Scripts._ @@ -351,9 +351,9 @@ object Helpers { commitments.params.maxHtlcAmount } - def getRelayFees(nodeParams: NodeParams, remoteNodeId: PublicKey, announceChannel: Boolean): RelayFees = { + def getRelayFees(nodeParams: NodeParams, remoteNodeId: PublicKey, announceChannel: Boolean): (RelayFees, Option[InboundFees]) = { val defaultFees = nodeParams.relayParams.defaultFees(announceChannel) - nodeParams.db.peers.getRelayFees(remoteNodeId).getOrElse(defaultFees) + (nodeParams.db.peers.getRelayFees(remoteNodeId).getOrElse(defaultFees), nodeParams.db.inboundFees.getInboundFees(remoteNodeId)) } object Funding { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala index 34ac08c7ff..c47af23ff6 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala @@ -46,6 +46,7 @@ import fr.acinq.eclair.db.PendingCommandsDb import fr.acinq.eclair.io.Peer import fr.acinq.eclair.io.Peer.LiquidityPurchaseSigned import fr.acinq.eclair.payment.relay.Relayer +import fr.acinq.eclair.payment.relay.Relayer.InboundFees import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentSettlingOnChain} import fr.acinq.eclair.router.Announcements import fr.acinq.eclair.transactions.Transactions.ClosingTx @@ -390,12 +391,13 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with case normal: DATA_NORMAL => context.system.eventStream.publish(ShortChannelIdAssigned(self, normal.channelId, normal.lastAnnouncement_opt, normal.aliases, remoteNodeId)) // we check the configuration because the values for channel_update may have changed while eclair was down - val fees = getRelayFees(nodeParams, remoteNodeId, normal.commitments.announceChannel) + val (fees, inboundFees_opt) = getRelayFees(nodeParams, remoteNodeId, normal.commitments.announceChannel) if (fees.feeBase != normal.channelUpdate.feeBaseMsat || fees.feeProportionalMillionths != normal.channelUpdate.feeProportionalMillionths || + inboundFees_opt != normal.channelUpdate.blip18InboundFees_opt || nodeParams.channelConf.expiryDelta != normal.channelUpdate.cltvExpiryDelta) { log.debug("refreshing channel_update due to configuration changes") - self ! CMD_UPDATE_RELAY_FEE(ActorRef.noSender, fees.feeBase, fees.feeProportionalMillionths) + self ! CMD_UPDATE_RELAY_FEE(ActorRef.noSender, fees.feeBase, fees.feeProportionalMillionths, inboundFees_opt.map(_.feeBase), inboundFees_opt.map(_.feeProportionalMillionths)) } // we need to periodically re-send channel updates, otherwise channel will be considered stale and get pruned by network // we take into account the date of the last update so that we don't send superfluous updates when we restart the app @@ -825,7 +827,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with log.info("announcing channelId={} on the network with shortChannelId={} for fundingTxIndex={}", d.channelId, localAnnSigs.shortChannelId, c.fundingTxIndex) // We generate a new channel_update because we can now use the scid of the announced funding transaction. val scidForChannelUpdate = Helpers.scidForChannelUpdate(Some(channelAnn), d.aliases.localAlias) - val channelUpdate = Announcements.makeChannelUpdate(nodeParams, remoteNodeId, scidForChannelUpdate, d.commitments.params, d.channelUpdate.relayFees, Helpers.maxHtlcAmount(nodeParams, d.commitments), enable = true) + val channelUpdate = Announcements.makeChannelUpdate(nodeParams, remoteNodeId, scidForChannelUpdate, d.commitments.params, d.channelUpdate.relayFees, Helpers.maxHtlcAmount(nodeParams, d.commitments), enable = true, d.channelUpdate.blip18InboundFees_opt) context.system.eventStream.publish(ShortChannelIdAssigned(self, d.channelId, Some(channelAnn), d.aliases, remoteNodeId)) // We use goto() instead of stay() because we want to fire transitions. goto(NORMAL) using d.copy(lastAnnouncement_opt = Some(channelAnn), channelUpdate = channelUpdate) storing() @@ -847,7 +849,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with } case Event(c: CMD_UPDATE_RELAY_FEE, d: DATA_NORMAL) => - val channelUpdate1 = Announcements.makeChannelUpdate(nodeParams, remoteNodeId, scidForChannelUpdate(d), d.commitments.params, Relayer.RelayFees(c.feeBase, c.feeProportionalMillionths), Helpers.maxHtlcAmount(nodeParams, d.commitments), enable = true) + val channelUpdate1 = Announcements.makeChannelUpdate(nodeParams, remoteNodeId, scidForChannelUpdate(d), d.commitments.params, Relayer.RelayFees(c.feeBase, c.feeProportionalMillionths), Helpers.maxHtlcAmount(nodeParams, d.commitments), enable = true, InboundFees.fromOptions(c.inboundFeeBase_opt, c.inboundFeeProportionalMillionths_opt)) log.debug(s"updating relay fees: prev={} next={}", d.channelUpdate.toStringShort, channelUpdate1.toStringShort) val replyTo = if (c.replyTo == ActorRef.noSender) sender() else c.replyTo replyTo ! RES_SUCCESS(c, d.channelId) @@ -856,7 +858,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with case Event(BroadcastChannelUpdate(reason), d: DATA_NORMAL) => val age = TimestampSecond.now() - d.channelUpdate.timestamp - val channelUpdate1 = Announcements.makeChannelUpdate(nodeParams, remoteNodeId, scidForChannelUpdate(d), d.commitments.params, d.channelUpdate.relayFees, Helpers.maxHtlcAmount(nodeParams, d.commitments), enable = true) + val channelUpdate1 = Announcements.makeChannelUpdate(nodeParams, remoteNodeId, scidForChannelUpdate(d), d.commitments.params, d.channelUpdate.relayFees, Helpers.maxHtlcAmount(nodeParams, d.commitments), enable = true, d.channelUpdate.blip18InboundFees_opt) reason match { case Reconnected if d.commitments.announceChannel && Announcements.areSame(channelUpdate1, d.channelUpdate) && age < REFRESH_CHANNEL_UPDATE_INTERVAL => // we already sent an identical channel_update not long ago (flapping protection in case we keep being disconnected/reconnected) @@ -1447,7 +1449,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with // if we have pending unsigned htlcs, then we cancel them and generate an update with the disabled flag set, that will be returned to the sender in a temporary channel failure if (d.commitments.changes.localChanges.proposed.collectFirst { case add: UpdateAddHtlc => add }.isDefined) { log.debug("updating channel_update announcement (reason=disabled)") - val channelUpdate1 = Announcements.makeChannelUpdate(nodeParams, remoteNodeId, scidForChannelUpdate(d), d.commitments.params, d.channelUpdate.relayFees, Helpers.maxHtlcAmount(nodeParams, d.commitments), enable = false) + val channelUpdate1 = Announcements.makeChannelUpdate(nodeParams, remoteNodeId, scidForChannelUpdate(d), d.commitments.params, d.channelUpdate.relayFees, Helpers.maxHtlcAmount(nodeParams, d.commitments), enable = false, d.channelUpdate.blip18InboundFees_opt) // NB: the htlcs stay in the commitments.localChange, they will be cleaned up after reconnection d.commitments.changes.localChanges.proposed.collect { case add: UpdateAddHtlc => relayer ! RES_ADD_SETTLED(d.commitments.originChannels(add.id), add, HtlcResult.DisconnectedBeforeSigned(channelUpdate1)) @@ -2941,7 +2943,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with log.debug("emitting channel down event") if (d.lastAnnouncement_opt.nonEmpty) { // We tell the rest of the network that this channel shouldn't be used anymore. - val disabledUpdate = Announcements.makeChannelUpdate(nodeParams, remoteNodeId, Helpers.scidForChannelUpdate(d), d.commitments.params, d.channelUpdate.relayFees, Helpers.maxHtlcAmount(nodeParams, d.commitments), enable = false) + val disabledUpdate = Announcements.makeChannelUpdate(nodeParams, remoteNodeId, Helpers.scidForChannelUpdate(d), d.commitments.params, d.channelUpdate.relayFees, Helpers.maxHtlcAmount(nodeParams, d.commitments), enable = false, d.channelUpdate.blip18InboundFees_opt) context.system.eventStream.publish(LocalChannelUpdate(self, d.channelId, d.aliases, remoteNodeId, d.lastAnnouncedCommitment_opt, disabledUpdate, d.commitments)) } val lcd = LocalChannelDown(self, d.channelId, d.commitments.all.flatMap(_.shortChannelId_opt), d.aliases, remoteNodeId) @@ -3134,7 +3136,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with if (d.channelUpdate.channelFlags.isEnabled) { // if the channel isn't disabled we generate a new channel_update log.debug("updating channel_update announcement (reason=disabled)") - val channelUpdate1 = Announcements.makeChannelUpdate(nodeParams, remoteNodeId, scidForChannelUpdate(d), d.commitments.params, d.channelUpdate.relayFees, Helpers.maxHtlcAmount(nodeParams, d.commitments), enable = false) + val channelUpdate1 = Announcements.makeChannelUpdate(nodeParams, remoteNodeId, scidForChannelUpdate(d), d.commitments.params, d.channelUpdate.relayFees, Helpers.maxHtlcAmount(nodeParams, d.commitments), enable = false, d.channelUpdate.blip18InboundFees_opt) // then we update the state and replay the request self forward c // we use goto() to fire transitions @@ -3147,7 +3149,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with } private def handleUpdateRelayFeeDisconnected(c: CMD_UPDATE_RELAY_FEE, d: DATA_NORMAL) = { - val channelUpdate1 = Announcements.makeChannelUpdate(nodeParams, remoteNodeId, scidForChannelUpdate(d), d.commitments.params, Relayer.RelayFees(c.feeBase, c.feeProportionalMillionths), Helpers.maxHtlcAmount(nodeParams, d.commitments), enable = false) + val channelUpdate1 = Announcements.makeChannelUpdate(nodeParams, remoteNodeId, scidForChannelUpdate(d), d.commitments.params, Relayer.RelayFees(c.feeBase, c.feeProportionalMillionths), Helpers.maxHtlcAmount(nodeParams, d.commitments), enable = false, InboundFees.fromOptions(c.inboundFeeBase_opt, c.inboundFeeProportionalMillionths_opt)) log.debug(s"updating relay fees: prev={} next={}", d.channelUpdate.toStringShort, channelUpdate1.toStringShort) val replyTo = if (c.replyTo == ActorRef.noSender) sender() else c.replyTo replyTo ! RES_SUCCESS(c, d.channelId) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonFundingHandlers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonFundingHandlers.scala index 087a458e99..72e59ef82d 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonFundingHandlers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonFundingHandlers.scala @@ -17,7 +17,6 @@ package fr.acinq.eclair.channel.fsm import akka.actor.typed.scaladsl.adapter.{TypedActorRefOps, actorRefAdapter} -import com.softwaremill.quicklens.ModifyPimp import fr.acinq.bitcoin.ScriptFlags import fr.acinq.bitcoin.scalacompat.{ByteVector32, Transaction, TxId} import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ @@ -139,8 +138,8 @@ trait CommonFundingHandlers extends CommonHandlers { // We create a channel_update early so that we can use it to send payments through this channel, but it won't be propagated to other nodes since the channel is not yet announced. val scidForChannelUpdate = Helpers.scidForChannelUpdate(channelAnnouncement_opt = None, aliases1.localAlias) log.info("using shortChannelId={} for initial channel_update", scidForChannelUpdate) - val relayFees = getRelayFees(nodeParams, remoteNodeId, commitments.announceChannel) - val initialChannelUpdate = Announcements.makeChannelUpdate(nodeParams, remoteNodeId, scidForChannelUpdate, commitments.params, relayFees, Helpers.maxHtlcAmount(nodeParams, commitments), enable = true) + val (relayFees, inboundFees_opt) = getRelayFees(nodeParams, remoteNodeId, commitments.announceChannel) + val initialChannelUpdate = Announcements.makeChannelUpdate(nodeParams, remoteNodeId, scidForChannelUpdate, commitments.params, relayFees, Helpers.maxHtlcAmount(nodeParams, commitments), enable = true, inboundFees_opt) // We need to periodically re-send channel updates, otherwise channel will be considered stale and get pruned by network. context.system.scheduler.scheduleWithFixedDelay(initialDelay = REFRESH_CHANNEL_UPDATE_INTERVAL, delay = REFRESH_CHANNEL_UPDATE_INTERVAL, receiver = self, message = BroadcastChannelUpdate(PeriodicRefresh)) val commitments1 = commitments.copy( diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/Databases.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/Databases.scala index c7f929f572..d123adea14 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/Databases.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/Databases.scala @@ -46,6 +46,7 @@ trait Databases { def offers: OffersDb def pendingCommands: PendingCommandsDb def liquidity: LiquidityDb + def inboundFees: InboundFeesDb //@formatter:on } @@ -69,6 +70,7 @@ object Databases extends Logging { payments: SqlitePaymentsDb, offers: SqliteOffersDb, pendingCommands: SqlitePendingCommandsDb, + inboundFees: SqliteInboundFeesDb, private val backupConnection: Connection) extends Databases with FileBackup { override def backup(backupFile: File): Unit = SqliteUtils.using(backupConnection.createStatement()) { statement => { @@ -78,7 +80,7 @@ object Databases extends Logging { } object SqliteDatabases { - def apply(auditJdbc: Connection, networkJdbc: Connection, eclairJdbc: Connection, jdbcUrlFile_opt: Option[File]): SqliteDatabases = { + def apply(auditJdbc: Connection, networkJdbc: Connection, eclairJdbc: Connection, inboundFeesJdbc: Connection, jdbcUrlFile_opt: Option[File]): SqliteDatabases = { jdbcUrlFile_opt.foreach(checkIfDatabaseUrlIsUnchanged("sqlite", _)) SqliteDatabases( network = new SqliteNetworkDb(networkJdbc), @@ -89,6 +91,7 @@ object Databases extends Logging { payments = new SqlitePaymentsDb(eclairJdbc), offers = new SqliteOffersDb(eclairJdbc), pendingCommands = new SqlitePendingCommandsDb(eclairJdbc), + inboundFees = new SqliteInboundFeesDb(inboundFeesJdbc), backupConnection = eclairJdbc ) } @@ -102,6 +105,7 @@ object Databases extends Logging { payments: PgPaymentsDb, offers: PgOffersDb, pendingCommands: PgPendingCommandsDb, + inboundFees: PgInboundFeesDb, dataSource: HikariDataSource, lock: PgLock) extends Databases with ExclusiveLock { override def obtainExclusiveLock(): Unit = lock.obtainExclusiveLock(dataSource) @@ -163,6 +167,7 @@ object Databases extends Logging { payments = new PgPaymentsDb, offers = new PgOffersDb, pendingCommands = new PgPendingCommandsDb, + inboundFees = new PgInboundFeesDb, dataSource = ds, lock = lock) @@ -310,6 +315,7 @@ object Databases extends Logging { eclairJdbc = SqliteUtils.openSqliteFile(dbdir, "eclair.sqlite", exclusiveLock = true, journalMode = "wal", syncFlag = "full"), // there should only be one process writing to this file networkJdbc = SqliteUtils.openSqliteFile(dbdir, "network.sqlite", exclusiveLock = false, journalMode = "wal", syncFlag = "normal"), // we don't need strong durability guarantees on the network db auditJdbc = SqliteUtils.openSqliteFile(dbdir, "audit.sqlite", exclusiveLock = false, journalMode = "wal", syncFlag = "full"), + inboundFeesJdbc = SqliteUtils.openSqliteFile(dbdir, "inboundfees.sqlite", exclusiveLock = false, journalMode = "wal", syncFlag = "full"), jdbcUrlFile_opt = jdbcUrlFile_opt ) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala index ee7ce61966..552af8ba7d 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/DualDatabases.scala @@ -10,7 +10,7 @@ import fr.acinq.eclair.db.DbEventHandler.ChannelEvent import fr.acinq.eclair.db.DualDatabases.runAsync import fr.acinq.eclair.payment._ import fr.acinq.eclair.payment.relay.OnTheFlyFunding -import fr.acinq.eclair.payment.relay.Relayer.RelayFees +import fr.acinq.eclair.payment.relay.Relayer.{InboundFees, RelayFees} import fr.acinq.eclair.router.Router import fr.acinq.eclair.wire.protocol._ import fr.acinq.eclair.{CltvExpiry, Features, InitFeature, MilliSatoshi, Paginated, RealShortChannelId, ShortChannelId, TimestampMilli, TimestampSecond} @@ -39,6 +39,7 @@ case class DualDatabases(primary: Databases, secondary: Databases) extends Datab override val offers: OffersDb = DualOffersDb(primary.offers, secondary.offers) override val pendingCommands: PendingCommandsDb = DualPendingCommandsDb(primary.pendingCommands, secondary.pendingCommands) override val liquidity: LiquidityDb = DualLiquidityDb(primary.liquidity, secondary.liquidity) + override val inboundFees: InboundFeesDb = DualInboundFeesDb(primary.inboundFees, secondary.inboundFees) /** if one of the database supports file backup, we use it */ override def backup(backupFile: File): Unit = (primary, secondary) match { @@ -521,3 +522,18 @@ case class DualLiquidityDb(primary: LiquidityDb, secondary: LiquidityDb) extends } } + + +case class DualInboundFeesDb(primary: InboundFeesDb, secondary: InboundFeesDb) extends InboundFeesDb { + private implicit val ec: ExecutionContext = ExecutionContext.fromExecutor(Executors.newSingleThreadExecutor(new ThreadFactoryBuilder().setNameFormat("db-liquidity").build())) + + override def addOrUpdateInboundFees(nodeId: PublicKey, fees: InboundFees): Unit = { + runAsync(secondary.addOrUpdateInboundFees(nodeId, fees)) + primary.addOrUpdateInboundFees(nodeId, fees) + } + + override def getInboundFees(nodeId: PublicKey): Option[InboundFees] = { + runAsync(secondary.getInboundFees(nodeId)) + primary.getInboundFees(nodeId) + } +} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/InboundFeesDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/InboundFeesDb.scala new file mode 100644 index 0000000000..e97d49f7d3 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/InboundFeesDb.scala @@ -0,0 +1,13 @@ +package fr.acinq.eclair.db + +import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey +import fr.acinq.eclair.payment.relay.Relayer.InboundFees + +/** The PeersDb contains information about our direct peers, with whom we have or had channels. */ +trait InboundFeesDb { + + def addOrUpdateInboundFees(nodeId: PublicKey, fees: InboundFees): Unit + + def getInboundFees(nodeId: PublicKey): Option[InboundFees] + +} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgInboundFeesDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgInboundFeesDb.scala new file mode 100644 index 0000000000..bcbef5111f --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgInboundFeesDb.scala @@ -0,0 +1,70 @@ +package fr.acinq.eclair.db.pg + +import fr.acinq.bitcoin.scalacompat.Crypto +import fr.acinq.eclair.MilliSatoshi +import fr.acinq.eclair.db.InboundFeesDb +import fr.acinq.eclair.db.Monitoring.Metrics.withMetrics +import fr.acinq.eclair.db.Monitoring.Tags.DbBackends +import fr.acinq.eclair.db.pg.PgUtils.PgLock +import fr.acinq.eclair.db.pg.PgUtils.PgLock.NoLock.withLock +import fr.acinq.eclair.payment.relay.Relayer.InboundFees +import grizzled.slf4j.Logging + +import javax.sql.DataSource + +object PgInboundFeesDb { + val DB_NAME = "inboundfees" + val CURRENT_VERSION = 1 +} + +class PgInboundFeesDb(implicit ds: DataSource, lock: PgLock) extends InboundFeesDb with Logging { + + import PgUtils._ + import ExtendedResultSet._ + import PgInboundFeesDb._ + + inTransaction { pg => + using(pg.createStatement()) { statement => + getVersion(statement, DB_NAME) match { + case None => + statement.executeUpdate("CREATE SCHEMA inboundfees") + statement.executeUpdate("CREATE TABLE inboundfees.inbound_fees (node_id TEXT NOT NULL PRIMARY KEY, fee_base_msat BIGINT NOT NULL, fee_proportional_millionths BIGINT NOT NULL)") + case Some(CURRENT_VERSION) => () // table is up-to-date, nothing to do + case Some(unknownVersion) => throw new RuntimeException(s"Unknown version of DB $DB_NAME found, version=$unknownVersion") + } + setVersion(statement, DB_NAME, CURRENT_VERSION) + } + } + + override def addOrUpdateInboundFees(nodeId: Crypto.PublicKey, fees: InboundFees): Unit = withMetrics("peers/add-or-update-relay-fees", DbBackends.Postgres) { + withLock { pg => + using(pg.prepareStatement( + """ + INSERT INTO inboundfees.inbound_fees (node_id, fee_base_msat, fee_proportional_millionths) + VALUES (?, ?, ?) + ON CONFLICT (node_id) + DO UPDATE SET fee_base_msat = EXCLUDED.fee_base_msat, fee_proportional_millionths = EXCLUDED.fee_proportional_millionths + """)) { statement => + statement.setString(1, nodeId.value.toHex) + statement.setLong(2, fees.feeBase.toLong) + statement.setLong(3, fees.feeProportionalMillionths) + statement.executeUpdate() + } + } + } + + override def getInboundFees(nodeId: Crypto.PublicKey): Option[InboundFees] = withMetrics("peers/get-relay-fees", DbBackends.Postgres) { + withLock { pg => + using(pg.prepareStatement("SELECT fee_base_msat, fee_proportional_millionths FROM inboundfees.inbound_fees WHERE node_id=?")) { statement => + statement.setString(1, nodeId.value.toHex) + statement.executeQuery() + .headOption + .map(rs => + InboundFees(MilliSatoshi(rs.getLong("fee_base_msat")), rs.getLong("fee_proportional_millionths")) + ) + } + } + } + +} + diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteInboundFeesDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteInboundFeesDb.scala new file mode 100644 index 0000000000..90c12e8c75 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteInboundFeesDb.scala @@ -0,0 +1,61 @@ +package fr.acinq.eclair.db.sqlite + +import fr.acinq.bitcoin.scalacompat.Crypto +import fr.acinq.eclair.MilliSatoshi +import fr.acinq.eclair.db.InboundFeesDb +import fr.acinq.eclair.db.sqlite.SqliteUtils.{getVersion, setVersion, using} +import fr.acinq.eclair.payment.relay.Relayer +import fr.acinq.eclair.payment.relay.Relayer.{InboundFees, RelayFees} +import grizzled.slf4j.Logging + +import java.sql.Connection + +object SqliteInboundFeesDb { + val DB_NAME = "inboundfees" + val CURRENT_VERSION = 1 +} + +class SqliteInboundFeesDb(val sqlite: Connection) extends InboundFeesDb with Logging { + + import SqliteInboundFeesDb._ + import SqliteUtils.ExtendedResultSet._ + + using(sqlite.createStatement(), inTransaction = true) { statement => + getVersion(statement, DB_NAME) match { + case None => + statement.executeUpdate("CREATE TABLE inbound_fees (node_id BLOB NOT NULL PRIMARY KEY, fee_base_msat INTEGER NOT NULL, fee_proportional_millionths INTEGER NOT NULL)") + case Some(CURRENT_VERSION) => () // table is up-to-date, nothing to do + case Some(unknownVersion) => throw new RuntimeException(s"Unknown version of DB $DB_NAME found, version=$unknownVersion") + } + setVersion(statement, DB_NAME, CURRENT_VERSION) + } + + override def addOrUpdateInboundFees(nodeId: Crypto.PublicKey, fees: Relayer.InboundFees): Unit = { + using(sqlite.prepareStatement("UPDATE inbound_fees SET fee_base_msat=?, fee_proportional_millionths=? WHERE node_id=?")) { update => + update.setLong(1, fees.feeBase.toLong) + update.setLong(2, fees.feeProportionalMillionths) + update.setBytes(3, nodeId.value.toArray) + if (update.executeUpdate() == 0) { + using(sqlite.prepareStatement("INSERT INTO inbound_fees VALUES (?, ?, ?)")) { statement => + statement.setBytes(1, nodeId.value.toArray) + statement.setLong(2, fees.feeBase.toLong) + statement.setLong(3, fees.feeProportionalMillionths) + statement.executeUpdate() + } + } + } + } + + override def getInboundFees(nodeId: Crypto.PublicKey): Option[Relayer.InboundFees] = { + using(sqlite.prepareStatement("SELECT fee_base_msat, fee_proportional_millionths FROM inbound_fees WHERE node_id=?")) { statement => + statement.setBytes(1, nodeId.value.toArray) + statement.executeQuery() + .headOption + .map(rs => + InboundFees(MilliSatoshi(rs.getLong("fee_base_msat")), rs.getLong("fee_proportional_millionths")) + ) + } + + } + +} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/package.scala b/eclair-core/src/main/scala/fr/acinq/eclair/package.scala index 810a5527f0..ac41e02ffe 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/package.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/package.scala @@ -20,7 +20,7 @@ import fr.acinq.bitcoin.scalacompat.Crypto.PrivateKey import fr.acinq.bitcoin.scalacompat.KotlinUtils._ import fr.acinq.bitcoin.scalacompat._ import fr.acinq.eclair.crypto.StrongRandom -import fr.acinq.eclair.payment.relay.Relayer.RelayFees +import fr.acinq.eclair.payment.relay.Relayer.{InboundFees, RelayFees} import scodec.Attempt import scodec.bits.{BitVector, ByteVector} @@ -71,6 +71,19 @@ package object eclair { def nodeFee(relayFees: RelayFees, paymentAmount: MilliSatoshi): MilliSatoshi = nodeFee(relayFees.feeBase, relayFees.feeProportionalMillionths, paymentAmount) + def totalFee(amount: MilliSatoshi, baseFee: MilliSatoshi, proportionalFee: Long, inboundBaseFee_opt: Option[MilliSatoshi], inboundProportionalFee_opt: Option[Long]): MilliSatoshi = { + val outFee = nodeFee(baseFee, proportionalFee, amount) + val inFee = (for { + inboundBaseFee <- inboundBaseFee_opt + inboundProportionalFee <- inboundProportionalFee_opt + } yield nodeFee(inboundBaseFee, inboundProportionalFee, amount + outFee)).getOrElse(0 msat) + val totalFee = outFee + inFee + if (totalFee.toLong < 0) 0 msat else totalFee + } + + def totalFee(amount: MilliSatoshi, relayFees: RelayFees, inboundFees_opt: Option[InboundFees]): MilliSatoshi = + totalFee(amount, relayFees.feeBase, relayFees.feeProportionalMillionths, inboundFees_opt.map(_.feeBase), inboundFees_opt.map(_.feeProportionalMillionths)) + /** * @param baseFee fixed fee * @param proportionalFee proportional fee (millionths) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelay.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelay.scala index ca459e92d6..45b2a16450 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelay.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelay.scala @@ -34,12 +34,12 @@ import fr.acinq.eclair.payment.{ChannelPaymentRelayed, IncomingPaymentPacket} import fr.acinq.eclair.wire.protocol.FailureMessageCodecs.createBadOnionFailure import fr.acinq.eclair.wire.protocol.PaymentOnion.IntermediatePayload import fr.acinq.eclair.wire.protocol._ -import fr.acinq.eclair.{EncodedNodeId, Features, InitFeature, Logs, NodeParams, TimestampMilli, TimestampSecond, channel, nodeFee} +import fr.acinq.eclair.{EncodedNodeId, Features, InitFeature, Logs, NodeParams, TimestampMilli, TimestampSecond, channel, totalFee} import java.util.UUID import java.util.concurrent.TimeUnit import scala.concurrent.duration.DurationLong -import scala.util.Random +import scala.util.{Random, Try, Success, Failure} object ChannelRelay { @@ -50,6 +50,8 @@ object ChannelRelay { private case class WrappedForwardFailure(failure: Register.ForwardFailure[CMD_ADD_HTLC]) extends Command private case class WrappedAddResponse(res: CommandResponse[CMD_ADD_HTLC]) extends Command private case class WrappedOnTheFlyFundingResponse(result: Peer.ProposeOnTheFlyFundingResponse) extends Command + private case class WrappedChannelInfo(result: RES_GET_CHANNEL_INFO) extends Command + private case class WrappedChannelInfoFailure(failure: Register.ForwardFailure[CMD_GET_CHANNEL_INFO]) extends Command // @formatter:on // @formatter:off @@ -126,6 +128,8 @@ class ChannelRelay private(nodeParams: NodeParams, private val addResponseAdapter = context.messageAdapter[CommandResponse[CMD_ADD_HTLC]](WrappedAddResponse) private val forwardNodeIdFailureAdapter = context.messageAdapter[Register.ForwardNodeIdFailure[Peer.ProposeOnTheFlyFunding]](_ => WrappedOnTheFlyFundingResponse(Peer.ProposeOnTheFlyFundingResponse.NotAvailable("peer not found"))) private val onTheFlyFundingResponseAdapter = context.messageAdapter[Peer.ProposeOnTheFlyFundingResponse](WrappedOnTheFlyFundingResponse) + private val channelInfoAdapter = context.messageAdapter[RES_GET_CHANNEL_INFO](WrappedChannelInfo) + private val channelInfoFailureAdapter = context.messageAdapter[Register.ForwardFailure[CMD_GET_CHANNEL_INFO]](WrappedChannelInfoFailure) private val nextPathKey_opt = r.payload match { case payload: IntermediatePayload.ChannelRelay.Blinded => Some(payload.nextPathKey) @@ -173,29 +177,52 @@ class ChannelRelay private(nodeParams: NodeParams, } } - def relay(remoteFeatures_opt: Option[Features[InitFeature]], previousFailures: Seq[PreviouslyTried]): Behavior[Command] = { + def relay(remoteFeatures_opt: Option[Features[InitFeature]], previousFailures: Seq[PreviouslyTried], inboundChannelUpdate_opt: Option[Either[Unit, ChannelUpdate]] = None): Behavior[Command] = { Behaviors.receiveMessagePartial { case DoRelay => - if (previousFailures.isEmpty) { - val nextNodeId_opt = channels.headOption.map(_._2.nextNodeId) - context.log.info("relaying htlc #{} from channelId={} to requestedShortChannelId={} nextNode={}", r.add.id, r.add.channelId, requestedShortChannelId_opt, nextNodeId_opt.getOrElse("")) + if (nodeParams.routerConf.blip18InboundFees && inboundChannelUpdate_opt.isEmpty) { + register ! Register.Forward(channelInfoFailureAdapter, r.add.channelId, CMD_GET_CHANNEL_INFO(channelInfoAdapter)) + waitForInboundChannelInfo(remoteFeatures_opt, previousFailures) + } else { + if (previousFailures.isEmpty) { + val nextNodeId_opt = channels.headOption.map(_._2.nextNodeId) + context.log.info("relaying htlc #{} from channelId={} to requestedShortChannelId={} nextNode={}", r.add.id, r.add.channelId, requestedShortChannelId_opt, nextNodeId_opt.getOrElse("")) + } + context.log.debug("attempting relay previousAttempts={}", previousFailures.size) + handleRelay(remoteFeatures_opt, previousFailures, inboundChannelUpdate_opt.flatMap(_.toOption)) match { + case RelayFailure(cmdFail) => + Metrics.recordPaymentRelayFailed(Tags.FailureType(cmdFail), Tags.RelayType.Channel) + context.log.info("rejecting htlc reason={}", cmdFail.reason) + safeSendAndStop(r.add.channelId, cmdFail) + case RelayNeedsFunding(nextNodeId, cmdFail) => + // Note that in the channel relay case, we don't have any outgoing onion shared secrets. + val cmd = Peer.ProposeOnTheFlyFunding(onTheFlyFundingResponseAdapter, r.amountToForward, r.add.paymentHash, r.outgoingCltv, r.nextPacket, Nil, nextPathKey_opt, upstream) + register ! Register.ForwardNodeId(forwardNodeIdFailureAdapter, nextNodeId, cmd) + waitForOnTheFlyFundingResponse(cmdFail) + case RelaySuccess(selectedChannelId, cmdAdd) => + context.log.info("forwarding htlc #{} from channelId={} to channelId={}", r.add.id, r.add.channelId, selectedChannelId) + register ! Register.Forward(forwardFailureAdapter, selectedChannelId, cmdAdd) + waitForAddResponse(selectedChannelId, remoteFeatures_opt, previousFailures) + } } - context.log.debug("attempting relay previousAttempts={}", previousFailures.size) - handleRelay(remoteFeatures_opt, previousFailures) match { - case RelayFailure(cmdFail) => - Metrics.recordPaymentRelayFailed(Tags.FailureType(cmdFail), Tags.RelayType.Channel) - context.log.info("rejecting htlc reason={}", cmdFail.reason) - safeSendAndStop(r.add.channelId, cmdFail) - case RelayNeedsFunding(nextNodeId, cmdFail) => - // Note that in the channel relay case, we don't have any outgoing onion shared secrets. - val cmd = Peer.ProposeOnTheFlyFunding(onTheFlyFundingResponseAdapter, r.amountToForward, r.add.paymentHash, r.outgoingCltv, r.nextPacket, Nil, nextPathKey_opt, upstream) - register ! Register.ForwardNodeId(forwardNodeIdFailureAdapter, nextNodeId, cmd) - waitForOnTheFlyFundingResponse(cmdFail) - case RelaySuccess(selectedChannelId, cmdAdd) => - context.log.info("forwarding htlc #{} from channelId={} to channelId={}", r.add.id, r.add.channelId, selectedChannelId) - register ! Register.Forward(forwardFailureAdapter, selectedChannelId, cmdAdd) - waitForAddResponse(selectedChannelId, remoteFeatures_opt, previousFailures) + } + } + + private def waitForInboundChannelInfo(remoteFeatures_opt: Option[Features[InitFeature]], previousFailures: Seq[PreviouslyTried]): Behavior[Command] = { + Behaviors.receiveMessagePartial { + case WrappedChannelInfo(res) => + val inboundChannelUpdate_opt = res.data match { + case d: DATA_NORMAL => Some(Right(d.channelUpdate)) + case _ => + context.log.error("Cannot get channel info for {}: invalid channel state {}", res.channelId, res.state) + Some(Left(())) } + context.self ! DoRelay + relay(remoteFeatures_opt, previousFailures, inboundChannelUpdate_opt) + case WrappedChannelInfoFailure(failure) => + context.log.error("Cannot get channel info for {}", failure.fwd.channelId) + context.self ! DoRelay + relay(remoteFeatures_opt, previousFailures, Some(Left(()))) } } @@ -282,10 +309,10 @@ class ChannelRelay private(nodeParams: NodeParams, * - a CMD_FAIL_HTLC to be sent back upstream * - a CMD_ADD_HTLC to propagate downstream */ - private def handleRelay(remoteFeatures_opt: Option[Features[InitFeature]], previousFailures: Seq[PreviouslyTried]): RelayResult = { + private def handleRelay(remoteFeatures_opt: Option[Features[InitFeature]], previousFailures: Seq[PreviouslyTried], inboundChannelUpdate_opt: Option[ChannelUpdate]): RelayResult = { val alreadyTried = previousFailures.map(_.channelId) - selectPreferredChannel(alreadyTried) match { - case Some(outgoingChannel) => relayOrFail(outgoingChannel) + selectPreferredChannel(alreadyTried, inboundChannelUpdate_opt) match { + case Some(outgoingChannel) => relayOrFail(outgoingChannel, inboundChannelUpdate_opt) case None => // No more channels to try. val cmdFail = if (previousFailures.nonEmpty) { @@ -300,7 +327,7 @@ class ChannelRelay private(nodeParams: NodeParams, CMD_FAIL_HTLC(r.add.id, FailureReason.LocalFailure(UnknownNextPeer()), commit = true) } walletNodeId_opt match { - case Some(walletNodeId) if shouldAttemptOnTheFlyFunding(remoteFeatures_opt, previousFailures) => RelayNeedsFunding(walletNodeId, cmdFail) + case Some(walletNodeId) if shouldAttemptOnTheFlyFunding(remoteFeatures_opt, previousFailures, inboundChannelUpdate_opt) => RelayNeedsFunding(walletNodeId, cmdFail) case _ => RelayFailure(cmdFail) } } @@ -312,7 +339,7 @@ class ChannelRelay private(nodeParams: NodeParams, * * If no suitable channel is found we default to the originally requested channel. */ - private def selectPreferredChannel(alreadyTried: Seq[ByteVector32]): Option[OutgoingChannel] = { + private def selectPreferredChannel(alreadyTried: Seq[ByteVector32], inboundChannelUpdate_opt: Option[ChannelUpdate]): Option[OutgoingChannel] = { context.log.debug("selecting next channel with requestedShortChannelId={}", requestedShortChannelId_opt) // we filter out channels that we have already tried val candidateChannels: Map[ByteVector32, OutgoingChannel] = channels -- alreadyTried @@ -320,7 +347,7 @@ class ChannelRelay private(nodeParams: NodeParams, candidateChannels .values .map { channel => - val relayResult = relayOrFail(channel) + val relayResult = relayOrFail(channel, inboundChannelUpdate_opt) context.log.debug("candidate channel: channelId={} availableForSend={} capacity={} channelUpdate={} result={}", channel.channelId, channel.commitments.availableBalanceForSend, @@ -369,9 +396,9 @@ class ChannelRelay private(nodeParams: NodeParams, * channel, because some parameters don't match with our settings for that channel. In that case we directly fail the * htlc. */ - private def relayOrFail(outgoingChannel: OutgoingChannelParams): RelayResult = { + private def relayOrFail(outgoingChannel: OutgoingChannelParams, inboundChannelUpdate_opt: Option[ChannelUpdate]): RelayResult = { val update = outgoingChannel.channelUpdate - validateRelayParams(outgoingChannel) match { + validateRelayParams(outgoingChannel, inboundChannelUpdate_opt) match { case Some(fail) => RelayFailure(fail) case None if !update.channelFlags.isEnabled => @@ -382,14 +409,15 @@ class ChannelRelay private(nodeParams: NodeParams, } } - private def validateRelayParams(outgoingChannel: OutgoingChannelParams): Option[CMD_FAIL_HTLC] = { + private def validateRelayParams(outgoingChannel: OutgoingChannelParams, inboundChannelUpdate_opt: Option[ChannelUpdate]): Option[CMD_FAIL_HTLC] = { val update = outgoingChannel.channelUpdate // If our current channel update was recently created, we accept payments that used our previous channel update. val allowPreviousUpdate = TimestampSecond.now() - update.timestamp <= nodeParams.relayParams.enforcementDelay val prevUpdate_opt = if (allowPreviousUpdate) outgoingChannel.prevChannelUpdate else None val htlcMinimumOk = update.htlcMinimumMsat <= r.amountToForward || prevUpdate_opt.exists(_.htlcMinimumMsat <= r.amountToForward) val expiryDeltaOk = update.cltvExpiryDelta <= r.expiryDelta || prevUpdate_opt.exists(_.cltvExpiryDelta <= r.expiryDelta) - val feesOk = nodeFee(update.relayFees, r.amountToForward) <= r.relayFeeMsat || prevUpdate_opt.exists(u => nodeFee(u.relayFees, r.amountToForward) <= r.relayFeeMsat) + val feesOk = totalFee(r.amountToForward, update.relayFees, inboundChannelUpdate_opt.flatMap(_.blip18InboundFees_opt)) <= r.relayFeeMsat || + prevUpdate_opt.exists(u => totalFee(r.amountToForward, u.relayFees, inboundChannelUpdate_opt.flatMap(_.blip18InboundFees_opt)) <= r.relayFeeMsat) if (!htlcMinimumOk) { Some(CMD_FAIL_HTLC(r.add.id, FailureReason.LocalFailure(AmountBelowMinimum(r.amountToForward, Some(update))), commit = true)) } else if (!expiryDeltaOk) { @@ -402,7 +430,7 @@ class ChannelRelay private(nodeParams: NodeParams, } /** If we fail to relay a payment, we may want to attempt on-the-fly funding. */ - private def shouldAttemptOnTheFlyFunding(remoteFeatures_opt: Option[Features[InitFeature]], previousFailures: Seq[PreviouslyTried]): Boolean = { + private def shouldAttemptOnTheFlyFunding(remoteFeatures_opt: Option[Features[InitFeature]], previousFailures: Seq[PreviouslyTried], inboundChannelUpdate_opt: Option[ChannelUpdate]): Boolean = { val featureOk = Features.canUseFeature(nodeParams.features.initFeatures(), remoteFeatures_opt.getOrElse(Features.empty), Features.OnTheFlyFunding) // If we have a channel with the next node, we only want to perform on-the-fly funding for liquidity issues. val liquidityIssue = previousFailures.forall { @@ -411,7 +439,7 @@ class ChannelRelay private(nodeParams: NodeParams, } // If we have a channel with the next peer, but we skipped it because the sender is using invalid relay parameters, // we don't want to perform on-the-fly funding: the sender should send a valid payment first. - val relayParamsOk = channels.values.forall(c => validateRelayParams(c).isEmpty) + val relayParamsOk = channels.values.forall(c => validateRelayParams(c, inboundChannelUpdate_opt).isEmpty) featureOk && liquidityIssue && relayParamsOk } 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 ba594d8f17..4d7762bf2b 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 @@ -29,6 +29,7 @@ import fr.acinq.eclair.channel._ import fr.acinq.eclair.db.PendingCommandsDb import fr.acinq.eclair.payment._ import fr.acinq.eclair.wire.protocol._ +import fr.acinq.eclair._ import fr.acinq.eclair.{CltvExpiryDelta, Logs, MilliSatoshi, NodeParams, RealShortChannelId} import grizzled.slf4j.Logging @@ -141,6 +142,14 @@ object Relayer extends Logging { def apply(feeBaseInt32: Int, feeProportionalMillionthsInt32: Int): InboundFees = { InboundFees(MilliSatoshi(feeBaseInt32), feeProportionalMillionthsInt32) } + + def fromOptions(inboundFeeBase_opt: Option[MilliSatoshi], inboundFeeProportionalMillionths_opt: Option[Long]): Option[InboundFees] = { + if (inboundFeeBase_opt.isEmpty && inboundFeeProportionalMillionths_opt.isEmpty) { + None + } else { + Some(InboundFees(inboundFeeBase_opt.getOrElse(0.msat), inboundFeeProportionalMillionths_opt.getOrElse(0L))) + } + } } case class AsyncPaymentsParams(holdTimeoutBlocks: Int, cancelSafetyBeforeTimeout: CltvExpiryDelta) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/BlindedPathsResolver.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/BlindedPathsResolver.scala index df284bf46f..14b6add3c0 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/BlindedPathsResolver.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/BlindedPathsResolver.scala @@ -161,7 +161,7 @@ private class BlindedPathsResolver(nodeParams: NodeParams, resolved: Seq[ResolvedPath]): Behavior[Command] = { // Note that we default to private fees if we don't have a channel yet with that node. // The announceChannel parameter is ignored if we already have a channel. - val relayFees = getRelayFees(nodeParams, nextNodeId.publicKey, announceChannel = false) + val (relayFees, inboundFees_opt) = getRelayFees(nodeParams, nextNodeId.publicKey, announceChannel = false) val shouldRelay = paymentRelayData.paymentRelay.feeBase >= relayFees.feeBase && paymentRelayData.paymentRelay.feeProportionalMillionths >= relayFees.feeProportionalMillionths && paymentRelayData.paymentRelay.cltvExpiryDelta >= nodeParams.channelConf.expiryDelta diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/router/Announcements.scala b/eclair-core/src/main/scala/fr/acinq/eclair/router/Announcements.scala index 80aee61d55..9b78140391 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/router/Announcements.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/router/Announcements.scala @@ -19,7 +19,8 @@ package fr.acinq.eclair.router import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey, sha256, verifySignature} import fr.acinq.bitcoin.scalacompat.{BlockHash, ByteVector64, Crypto, LexicographicalOrdering} import fr.acinq.eclair.channel.ChannelParams -import fr.acinq.eclair.payment.relay.Relayer.RelayFees +import fr.acinq.eclair.payment.relay.Relayer.{InboundFees, RelayFees} +import fr.acinq.eclair.wire.protocol.ChannelUpdateTlv.Blip18InboundFee import fr.acinq.eclair.wire.protocol._ import fr.acinq.eclair.{CltvExpiryDelta, Feature, Features, MilliSatoshi, NodeFeature, NodeParams, RealShortChannelId, ShortChannelId, TimestampSecond, TimestampSecondLong, serializationResult} import scodec.bits.ByteVector @@ -122,14 +123,15 @@ object Announcements { u1.htlcMinimumMsat == u2.htlcMinimumMsat && u1.htlcMaximumMsat == u2.htlcMaximumMsat - def makeChannelUpdate(nodeParams: NodeParams, remoteNodeId: PublicKey, scid: ShortChannelId, params: ChannelParams, relayFees: RelayFees, maxHtlcAmount: MilliSatoshi, enable: Boolean): ChannelUpdate = { - makeChannelUpdate(nodeParams.chainHash, nodeParams.privateKey, remoteNodeId, scid, nodeParams.channelConf.expiryDelta, params.remoteParams.htlcMinimum, relayFees.feeBase, relayFees.feeProportionalMillionths, maxHtlcAmount, isPrivate = !params.announceChannel, enable) + def makeChannelUpdate(nodeParams: NodeParams, remoteNodeId: PublicKey, scid: ShortChannelId, params: ChannelParams, relayFees: RelayFees, maxHtlcAmount: MilliSatoshi, enable: Boolean, inboundFees_opt: Option[InboundFees]): ChannelUpdate = { + makeChannelUpdate(nodeParams.chainHash, nodeParams.privateKey, remoteNodeId, scid, nodeParams.channelConf.expiryDelta, params.remoteParams.htlcMinimum, relayFees.feeBase, relayFees.feeProportionalMillionths, maxHtlcAmount, isPrivate = !params.announceChannel, enable, inboundFees_opt = inboundFees_opt) } - def makeChannelUpdate(chainHash: BlockHash, nodeSecret: PrivateKey, remoteNodeId: PublicKey, shortChannelId: ShortChannelId, cltvExpiryDelta: CltvExpiryDelta, htlcMinimumMsat: MilliSatoshi, feeBaseMsat: MilliSatoshi, feeProportionalMillionths: Long, htlcMaximumMsat: MilliSatoshi, isPrivate: Boolean = false, enable: Boolean = true, timestamp: TimestampSecond = TimestampSecond.now()): ChannelUpdate = { + def makeChannelUpdate(chainHash: BlockHash, nodeSecret: PrivateKey, remoteNodeId: PublicKey, shortChannelId: ShortChannelId, cltvExpiryDelta: CltvExpiryDelta, htlcMinimumMsat: MilliSatoshi, feeBaseMsat: MilliSatoshi, feeProportionalMillionths: Long, htlcMaximumMsat: MilliSatoshi, isPrivate: Boolean = false, enable: Boolean = true, timestamp: TimestampSecond = TimestampSecond.now(), inboundFees_opt: Option[InboundFees] = None): ChannelUpdate = { val messageFlags = ChannelUpdate.MessageFlags(isPrivate) val channelFlags = ChannelUpdate.ChannelFlags(isNode1 = isNode1(nodeSecret.publicKey, remoteNodeId), isEnabled = enable) - val witness = channelUpdateWitnessEncode(chainHash, shortChannelId, timestamp, messageFlags, channelFlags, cltvExpiryDelta, htlcMinimumMsat, feeBaseMsat, feeProportionalMillionths, htlcMaximumMsat, TlvStream.empty) + val tlvStream = inboundFees_opt.map(fees => TlvStream[ChannelUpdateTlv](Blip18InboundFee(fees))).getOrElse(TlvStream.empty) + val witness = channelUpdateWitnessEncode(chainHash, shortChannelId, timestamp, messageFlags, channelFlags, cltvExpiryDelta, htlcMinimumMsat, feeBaseMsat, feeProportionalMillionths, htlcMaximumMsat, tlvStream) val sig = Crypto.sign(witness, nodeSecret) ChannelUpdate( signature = sig, @@ -142,7 +144,8 @@ object Announcements { htlcMinimumMsat = htlcMinimumMsat, feeBaseMsat = feeBaseMsat, feeProportionalMillionths = feeProportionalMillionths, - htlcMaximumMsat = htlcMaximumMsat + htlcMaximumMsat = htlcMaximumMsat, + tlvStream = tlvStream ) } 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 b8d6e7702e..fce5eb765f 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 @@ -101,11 +101,9 @@ object RouteCalculation { // some nodes in the supplied route aren't connected in our graph fr.replyTo ! PaymentRouteNotFound(new IllegalArgumentException("Not all the nodes in the supplied route are connected with public channels")) } - case pcr@PredefinedChannelRoute(amount, targetNodeId, shortChannelIds, maxFee_opt) => - log.info(s"$pcr") + case PredefinedChannelRoute(amount, targetNodeId, shortChannelIds, maxFee_opt) => val (end, hops) = shortChannelIds.foldLeft((localNodeId, Seq.empty[ChannelHop])) { case ((currentNode, previousHops), shortChannelId) => - log.info(s"d.resolve($shortChannelId)=${d.resolve(shortChannelId)}") val channelDesc_opt = d.resolve(shortChannelId) match { case Some(c: PublicChannel) => currentNode match { case c.nodeId1 => Some(ChannelDesc(shortChannelId, c.nodeId1, c.nodeId2)) @@ -119,13 +117,11 @@ object RouteCalculation { } case None => extraEdges.find(e => e.desc.shortChannelId == shortChannelId && e.desc.a == currentNode).map(_.desc) } - log.info(s"$channelDesc_opt") channelDesc_opt.flatMap(c => g.getEdge(c)) match { case Some(edge) => (edge.desc.b, previousHops :+ ChannelHop(getEdgeRelayScid(d, localNodeId, edge), edge.desc.a, edge.desc.b, edge.params)) case None => (currentNode, previousHops) } } - log.info(s"$end $hops") if (end != targetNodeId || hops.length != shortChannelIds.length) { fr.replyTo ! PaymentRouteNotFound(new IllegalArgumentException("The sequence of channels provided cannot be used to build a route to the target node")) } else { 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 2751613222..5069258f75 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 @@ -495,12 +495,7 @@ object Router { def cltvExpiryDelta: CltvExpiryDelta def relayFees: Relayer.RelayFees 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 - } + final def fee(amount: MilliSatoshi): MilliSatoshi = totalFee(amount, relayFees, inboundFees_opt) def htlcMinimum: MilliSatoshi def htlcMaximum_opt: Option[MilliSatoshi] // @formatter:on 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 4089b043fa..326dbd0084 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 @@ -16,6 +16,7 @@ package fr.acinq.eclair.wire.protocol +import fr.acinq.eclair.payment.relay.Relayer.InboundFees import fr.acinq.eclair.wire.protocol.CommonCodecs.{timestampSecond, varint, varintoverflow} import fr.acinq.eclair.wire.protocol.TlvCodecs.{tlvField, tlvStream} import fr.acinq.eclair.{TimestampSecond, UInt64} @@ -55,6 +56,10 @@ sealed trait ChannelUpdateTlv extends Tlv object ChannelUpdateTlv { case class Blip18InboundFee(feeBase: Int, feeProportionalMillionths: Int) extends ChannelUpdateTlv + object Blip18InboundFee { + def apply(fees: InboundFees): Blip18InboundFee = Blip18InboundFee(fees.feeBase.toLong.toInt, fees.feeProportionalMillionths.toInt) + } + private val blip18InboundFeeCodec: Codec[Blip18InboundFee] = tlvField(Codec( ("feeBase" | int32) :: ("feeProportionalMillionths" | int32) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala index b449d431e3..ea40123192 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala @@ -33,7 +33,7 @@ import fr.acinq.eclair.io.Peer import fr.acinq.eclair.io.Peer.OpenChannel import fr.acinq.eclair.payment.receive.MultiPartHandler.ReceiveStandardPayment import fr.acinq.eclair.payment.receive.PaymentHandler -import fr.acinq.eclair.payment.relay.Relayer.{GetOutgoingChannels, RelayFees} +import fr.acinq.eclair.payment.relay.Relayer.{GetOutgoingChannels, InboundFees, RelayFees} import fr.acinq.eclair.payment.send.PaymentIdentifier import fr.acinq.eclair.payment.send.PaymentInitiator._ import fr.acinq.eclair.payment.{Bolt11Invoice, Invoice, PaymentFailed} @@ -658,9 +658,11 @@ class EclairImplSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with I import f._ val peersDb = mock[PeersDb] + val inboundFeesDb = mock[InboundFeesDb] val databases = mock[Databases] databases.peers returns peersDb + databases.inboundFees returns inboundFeesDb val kitWithMockDb = kit.copy(nodeParams = kit.nodeParams.copy(db = databases)) val eclair = new EclairImpl(kitWithMockDb) @@ -672,7 +674,7 @@ class EclairImplSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with I val b1 = randomBytes32() val map = Map(a1 -> a, a2 -> a, b1 -> b) - eclair.updateRelayFee(List(a, b), 999 msat, 1234).pipeTo(sender.ref) + eclair.updateRelayFee(List(a, b), 999 msat, 1234, Some(1 msat), Some(2)).pipeTo(sender.ref) register.expectMsg(Register.GetChannelsTo) register.reply(map) @@ -686,13 +688,16 @@ class EclairImplSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with I register.expectNoMessage() assert(sender.expectMsgType[Map[ChannelIdentifier, Either[Throwable, CommandResponse[CMD_UPDATE_RELAY_FEE]]]] == Map( - Left(a1) -> Right(RES_SUCCESS(CMD_UPDATE_RELAY_FEE(ActorRef.noSender, 999 msat, 1234), a1)), - Left(a2) -> Right(RES_FAILURE(CMD_UPDATE_RELAY_FEE(ActorRef.noSender, 999 msat, 1234), CommandUnavailableInThisState(a2, "CMD_UPDATE_RELAY_FEE", channel.CLOSING))), + Left(a1) -> Right(RES_SUCCESS(CMD_UPDATE_RELAY_FEE(ActorRef.noSender, 999 msat, 1234, Some(1 msat), Some(2)), a1)), + Left(a2) -> Right(RES_FAILURE(CMD_UPDATE_RELAY_FEE(ActorRef.noSender, 999 msat, 1234, Some(1 msat), Some(2)), CommandUnavailableInThisState(a2, "CMD_UPDATE_RELAY_FEE", channel.CLOSING))), Left(b1) -> Left(ChannelNotFound(Left(b1))) )) peersDb.addOrUpdateRelayFees(a, RelayFees(999 msat, 1234)).wasCalled(once) peersDb.addOrUpdateRelayFees(b, RelayFees(999 msat, 1234)).wasCalled(once) + + inboundFeesDb.addOrUpdateInboundFees(a, InboundFees(1 msat, 2)).wasCalled(once) + inboundFeesDb.addOrUpdateInboundFees(b, InboundFees(1 msat, 2)).wasCalled(once) } test("channelBalances asks for all channels, usableBalances only for enabled ones") { f => diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala index dbb4775563..1f780a06b8 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala @@ -36,6 +36,7 @@ sealed trait TestDatabases extends Databases { override def offers: OffersDb = db.offers override def pendingCommands: PendingCommandsDb = db.pendingCommands override def liquidity: LiquidityDb = db.liquidity + override def inboundFees: InboundFeesDb = db.inboundFees def close(): Unit // @formatter:on } @@ -46,7 +47,7 @@ object TestDatabases { def inMemoryDb(): Databases = { val connection = sqliteInMemory() - val dbs = Databases.SqliteDatabases(connection, connection, connection, jdbcUrlFile_opt = None) + val dbs = Databases.SqliteDatabases(connection, connection, connection, connection, jdbcUrlFile_opt = None) dbs.copy(channels = new SqliteChannelsDbWithValidation(dbs.channels)) } @@ -102,7 +103,7 @@ object TestDatabases { override lazy val db: Databases = { val jdbcUrlFile: File = new File(TestUtils.BUILD_DIRECTORY, s"jdbcUrlFile_${UUID.randomUUID()}.tmp") jdbcUrlFile.deleteOnExit() - val dbs = Databases.SqliteDatabases(connection, connection, connection, jdbcUrlFile_opt = Some(jdbcUrlFile)) + val dbs = Databases.SqliteDatabases(connection, connection, connection, connection, jdbcUrlFile_opt = Some(jdbcUrlFile)) dbs.copy(channels = new SqliteChannelsDbWithValidation(dbs.channels)) } override def close(): Unit = connection.close() diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/db/DbMigrationSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/db/DbMigrationSpec.scala index a88168418b..1fd9f62311 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/db/DbMigrationSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/db/DbMigrationSpec.scala @@ -32,7 +32,7 @@ class DbMigrationSpec extends AnyFunSuite { new PgChannelsDb()(postgresDatasource, PgLock.NoLock) new PgPendingCommandsDb()(postgresDatasource, PgLock.NoLock) - new PgPeersDb()(postgresDatasource, PgLock.NoLock) + new PgInboundFeesDb()(postgresDatasource, PgLock.NoLock) new PgPaymentsDb()(postgresDatasource, PgLock.NoLock) PgUtils.inTransaction { postgres => @@ -83,6 +83,7 @@ class DbMigrationSpec extends AnyFunSuite { auditJdbc = loadSqlite("migration\\audit.sqlite", readOnly = false), eclairJdbc = loadSqlite("migration\\eclair.sqlite", readOnly = false), networkJdbc = loadSqlite("migration\\network.sqlite", readOnly = false), + inboundFeesJdbc = loadSqlite("migration\\inboundfees.sqlite", readOnly = false), jdbcUrlFile_opt = None ) val postgres = { diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/db/InboundFeesDbSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/db/InboundFeesDbSpec.scala new file mode 100644 index 0000000000..a896ed0d47 --- /dev/null +++ b/eclair-core/src/test/scala/fr/acinq/eclair/db/InboundFeesDbSpec.scala @@ -0,0 +1,45 @@ +package fr.acinq.eclair.db + +import fr.acinq.eclair.TestDatabases.{TestPgDatabases, TestSqliteDatabases} +import fr.acinq.eclair._ +import fr.acinq.eclair.db.pg.PgInboundFeesDb +import fr.acinq.eclair.db.sqlite.SqliteInboundFeesDb +import fr.acinq.eclair.payment.relay.Relayer.InboundFees +import org.scalatest.funsuite.AnyFunSuite + +class InboundFeesDbSpec extends AnyFunSuite { + + import fr.acinq.eclair.TestDatabases.forAllDbs + + test("init database two times in a row") { + forAllDbs { + case sqlite: TestSqliteDatabases => + new SqliteInboundFeesDb(sqlite.connection) + new SqliteInboundFeesDb(sqlite.connection) + case pg: TestPgDatabases => + new PgInboundFeesDb()(pg.datasource, pg.lock) + new PgInboundFeesDb()(pg.datasource, pg.lock) + } + } + + test("add and update inbound fees") { + forAllDbs { dbs => + val db = dbs.inboundFees + + val a = randomKey().publicKey + val b = randomKey().publicKey + + assert(db.getInboundFees(a).isEmpty) + assert(db.getInboundFees(b).isEmpty) + db.addOrUpdateInboundFees(a, InboundFees(1 msat, 123)) + assert(db.getInboundFees(a).contains(InboundFees(1 msat, 123))) + assert(db.getInboundFees(b).isEmpty) + db.addOrUpdateInboundFees(a, InboundFees(2 msat, 456)) + assert(db.getInboundFees(a).contains(InboundFees(2 msat, 456))) + assert(db.getInboundFees(b).isEmpty) + db.addOrUpdateInboundFees(b, InboundFees(3 msat, 789)) + assert(db.getInboundFees(a).contains(InboundFees(2 msat, 456))) + assert(db.getInboundFees(b).contains(InboundFees(3 msat, 789))) + } + } +} diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/ChannelRelayerSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/ChannelRelayerSpec.scala index eca6dea134..f02ac0d894 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/ChannelRelayerSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/ChannelRelayerSpec.scala @@ -34,9 +34,11 @@ import fr.acinq.eclair.crypto.Sphinx import fr.acinq.eclair.io.{Peer, PeerReadyManager, Switchboard} import fr.acinq.eclair.payment.IncomingPaymentPacket.ChannelRelayPacket import fr.acinq.eclair.payment.relay.ChannelRelayer._ +import fr.acinq.eclair.payment.relay.Relayer.InboundFees import fr.acinq.eclair.payment.{ChannelPaymentRelayed, IncomingPaymentPacket, PaymentPacketSpec} import fr.acinq.eclair.router.Announcements import fr.acinq.eclair.wire.protocol.BlindedRouteData.PaymentRelayData +import fr.acinq.eclair.wire.protocol.ChannelUpdateTlv.Blip18InboundFee import fr.acinq.eclair.wire.protocol.PaymentOnion.IntermediatePayload import fr.acinq.eclair.wire.protocol.PaymentOnion.IntermediatePayload.ChannelRelay import fr.acinq.eclair.wire.protocol._ @@ -513,6 +515,20 @@ class ChannelRelayerSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("a expectFwdFail(register, r.add.channelId, CMD_FAIL_HTLC(r.add.id, FailureReason.LocalFailure(FeeInsufficient(r.add.amountMsat, Some(u3.channelUpdate))), commit = true)) } + test("relay that would fail (fee insufficient) when inbound fees are set") { f => + import f._ + + val payload = ChannelRelay.Standard(realScid1, outgoingAmount, outgoingExpiry) + val r = createValidIncomingPacket(payload) + val u = createLocalUpdate(channelId1, inboundFees_opt = Some(InboundFees(10000 msat, 100000))) + + channelRelayer ! WrappedLocalChannelUpdate(u) + channelRelayer ! Relay(r, TestConstants.Alice.nodeParams.nodeId) + + expectFwdFail(register, r.add.channelId, CMD_FAIL_HTLC(r.add.id, FailureReason.LocalFailure(FeeInsufficient(r.add.amountMsat, Some(u.channelUpdate))), commit = true)) + } + + test("fail to relay when there is a local error") { f => import f._ @@ -835,11 +851,12 @@ object ChannelRelayerSpec { ShortIdAliases(localAlias, remoteAlias_opt = None) } - def createLocalUpdate(channelId: ByteVector32, channelUpdateScid_opt: Option[ShortChannelId] = None, balance: MilliSatoshi = 100_000_000 msat, capacity: Satoshi = 5_000_000 sat, enabled: Boolean = true, htlcMinimum: MilliSatoshi = 0 msat, timestamp: TimestampSecond = 0 unixsec, feeBaseMsat: MilliSatoshi = 1000 msat, feeProportionalMillionths: Long = 100, optionScidAlias: Boolean = false): LocalChannelUpdate = { + def createLocalUpdate(channelId: ByteVector32, channelUpdateScid_opt: Option[ShortChannelId] = None, balance: MilliSatoshi = 100_000_000 msat, capacity: Satoshi = 5_000_000 sat, enabled: Boolean = true, htlcMinimum: MilliSatoshi = 0 msat, timestamp: TimestampSecond = 0 unixsec, feeBaseMsat: MilliSatoshi = 1000 msat, feeProportionalMillionths: Long = 100, optionScidAlias: Boolean = false, inboundFees_opt: Option[InboundFees] = None): LocalChannelUpdate = { val aliases = createAliases(channelId) val realScid = channelIds.collectFirst { case (realScid: RealShortChannelId, cid) if cid == channelId => realScid }.get val channelUpdateScid = channelUpdateScid_opt.getOrElse(realScid) - val update = ChannelUpdate(ByteVector64(randomBytes(64)), Block.RegtestGenesisBlock.hash, channelUpdateScid, timestamp, ChannelUpdate.MessageFlags(dontForward = false), ChannelUpdate.ChannelFlags(isNode1 = true, isEnabled = enabled), CltvExpiryDelta(100), htlcMinimum, feeBaseMsat, feeProportionalMillionths, capacity.toMilliSatoshi) + val tlvStream = inboundFees_opt.map(fees => TlvStream[ChannelUpdateTlv](Blip18InboundFee(fees))).getOrElse(TlvStream.empty) + val update = ChannelUpdate(ByteVector64(randomBytes(64)), Block.RegtestGenesisBlock.hash, channelUpdateScid, timestamp, ChannelUpdate.MessageFlags(dontForward = false), ChannelUpdate.ChannelFlags(isNode1 = true, isEnabled = enabled), CltvExpiryDelta(100), htlcMinimum, feeBaseMsat, feeProportionalMillionths, capacity.toMilliSatoshi, tlvStream) val features: Set[PermanentChannelFeature] = Set( if (optionScidAlias) Some(ScidAlias) else None, ).flatten diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/router/AnnouncementsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/router/AnnouncementsSpec.scala index d336ed4993..f68f4ee09e 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/router/AnnouncementsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/router/AnnouncementsSpec.scala @@ -21,6 +21,7 @@ import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} import fr.acinq.eclair.TestConstants.Alice import fr.acinq.eclair.RealShortChannelId import fr.acinq.eclair._ +import fr.acinq.eclair.payment.relay.Relayer.InboundFees import fr.acinq.eclair.router.Announcements._ import fr.acinq.eclair.wire.protocol.ChannelUpdate.ChannelFlags import fr.acinq.eclair.wire.protocol.LightningMessageCodecs.nodeAnnouncementCodec @@ -135,6 +136,9 @@ class AnnouncementsSpec extends AnyFunSuite { val ann = makeChannelUpdate(Block.RegtestGenesisBlock.hash, Alice.nodeParams.privateKey, randomKey().publicKey, ShortChannelId(45561L), Alice.nodeParams.channelConf.expiryDelta, Alice.nodeParams.channelConf.htlcMinimum, Alice.nodeParams.relayParams.publicChannelFees.feeBase, Alice.nodeParams.relayParams.publicChannelFees.feeProportionalMillionths, 500000000 msat) assert(checkSig(ann, Alice.nodeParams.nodeId)) assert(!checkSig(ann, randomKey().publicKey)) + val annInboundFees = makeChannelUpdate(Block.RegtestGenesisBlock.hash, Alice.nodeParams.privateKey, randomKey().publicKey, ShortChannelId(45561L), Alice.nodeParams.channelConf.expiryDelta, Alice.nodeParams.channelConf.htlcMinimum, Alice.nodeParams.relayParams.publicChannelFees.feeBase, Alice.nodeParams.relayParams.publicChannelFees.feeProportionalMillionths, 500000000 msat, inboundFees_opt = Some(InboundFees(1 msat, 1))) + assert(checkSig(annInboundFees, Alice.nodeParams.nodeId)) + assert(!checkSig(annInboundFees.copy(tlvStream = TlvStream.empty), Alice.nodeParams.nodeId)) } test("check flags") { diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Fees.scala b/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Fees.scala index d83f8a6409..ba2144cd51 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Fees.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Fees.scala @@ -16,7 +16,7 @@ package fr.acinq.eclair.api.handlers -import akka.http.scaladsl.server.Route +import akka.http.scaladsl.server.{MalformedFormFieldRejection, Route} import fr.acinq.eclair.MilliSatoshi import fr.acinq.eclair.api.Service import fr.acinq.eclair.api.directives.EclairDirectives @@ -35,8 +35,18 @@ trait Fees { val updateRelayFee: Route = postRequest("updaterelayfee") { implicit t => withNodesIdentifier { nodes => - formFields("feeBaseMsat".as[MilliSatoshi], "feeProportionalMillionths".as[Long]) { (feeBase, feeProportional) => - complete(eclairApi.updateRelayFee(nodes, feeBase, feeProportional)) + formFields("feeBaseMsat".as[MilliSatoshi], "feeProportionalMillionths".as[Long], "inboundFeeBaseMsat".as[MilliSatoshi]?, "inboundFeeProportionalMillionths".as[Long]?) { (feeBase, feeProportional, inboundFeeBase_opt, inboundFeeProportional_opt) => + if (inboundFeeBase_opt.isEmpty && inboundFeeProportional_opt.isDefined) { + reject(MalformedFormFieldRejection("inboundFeeBaseMsat", "inbound fee base is required")) + } else if (inboundFeeBase_opt.isDefined && inboundFeeProportional_opt.isEmpty) { + reject(MalformedFormFieldRejection("inboundFeeProportionalMillionths", "inbound fee proportional millionths is required")) + } else if (!inboundFeeBase_opt.forall(value => value.toLong >= Int.MinValue && value.toLong <= 0)) { + reject(MalformedFormFieldRejection("inboundFeeBaseMsat", s"inbound fee base must be must be in the range from ${Int.MinValue} to 0")) + } else if (!inboundFeeProportional_opt.forall(value => value >= Int.MinValue && value <= 0)) { + reject(MalformedFormFieldRejection("inboundFeeProportionalMillionths", s"inbound fee proportional millionths must be in the range from ${Int.MinValue} to 0")) + } else { + complete(eclairApi.updateRelayFee(nodes, feeBase, feeProportional, inboundFeeBase_opt, inboundFeeProportional_opt)) + } } } } From a7999f968a3e0f1c6ac93ff6e4816b5374a5817a Mon Sep 17 00:00:00 2001 From: rorp Date: Tue, 6 Jan 2026 20:48:36 -0800 Subject: [PATCH 04/11] blip18 weighted --- .gitignore | 1 + .../acinq/eclair/json/JsonSerializers.scala | 4 +- .../scala/fr/acinq/eclair/router/Graph.scala | 190 +- .../eclair/router/RouteCalculation.scala | 76 +- .../router/Blip18FinalizeRouteSpec.scala | 393 +++ .../router/Blip18RouteCalculationSpec.scala | 2594 +++++++++++++++-- .../eclair/router/RouteCalculationSpec.scala | 4 +- .../src/test/resources/api/findroute-full | 2 +- 8 files changed, 3010 insertions(+), 254 deletions(-) create mode 100644 eclair-core/src/test/scala/fr/acinq/eclair/router/Blip18FinalizeRouteSpec.scala diff --git a/.gitignore b/.gitignore index a25470c558..c7404162d5 100644 --- a/.gitignore +++ b/.gitignore @@ -28,5 +28,6 @@ DeleteMe*.* jdbcUrlFile_*.tmp .metals/ .vscode/ +.bloop/ .DS_Store diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/json/JsonSerializers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/json/JsonSerializers.scala index f2e6871fd0..f9c240cb5b 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/json/JsonSerializers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/json/JsonSerializers.scala @@ -327,14 +327,14 @@ private sealed trait HopJson private case class ChannelHopJson(nodeId: PublicKey, nextNodeId: PublicKey, source: HopRelayParams) extends HopJson private case class BlindedHopJson(nodeId: PublicKey, nextNodeId: PublicKey, paymentInfo: OfferTypes.PaymentInfo) extends HopJson private case class NodeHopJson(nodeId: PublicKey, nextNodeId: PublicKey, fee: MilliSatoshi, cltvExpiryDelta: CltvExpiryDelta) extends HopJson -private case class RouteFullJson(amount: MilliSatoshi, hops: Seq[HopJson]) +private case class RouteFullJson(amount: MilliSatoshi, hops: Seq[HopJson], fee: MilliSatoshi) object RouteFullSerializer extends ConvertClassSerializer[Route](route => { val channelHops = route.hops.map(h => ChannelHopJson(h.nodeId, h.nextNodeId, h.params)) val finalHop_opt = route.finalHop_opt.map { case h: NodeHop => NodeHopJson(h.nodeId, h.nextNodeId, h.fee, h.cltvExpiryDelta) case h: BlindedHop => BlindedHopJson(h.nodeId, h.nextNodeId, h.paymentInfo) } - RouteFullJson(route.amount, channelHops ++ finalHop_opt.toSeq) + RouteFullJson(route.amount, channelHops ++ finalHop_opt.toSeq, route.channelFee(false)) }) private case class RouteNodeIdsJson(amount: MilliSatoshi, nodeIds: Seq[PublicKey]) 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 93956c4db7..a6f9afb4e6 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 @@ -20,6 +20,7 @@ import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.bitcoin.scalacompat.{Btc, MilliBtc, Satoshi} import fr.acinq.eclair._ import fr.acinq.eclair.payment.Invoice +import fr.acinq.eclair.payment.relay.Relayer import fr.acinq.eclair.payment.relay.Relayer.RelayFees import fr.acinq.eclair.router.Router.HopRelayParams import fr.acinq.eclair.router.Graph.GraphStructure.GraphEdge @@ -49,7 +50,7 @@ object Graph { * @param fees total fees of the path * @param weight cost multiplied by a factor based on heuristics (see [[PaymentWeightRatios]]). */ - case class PaymentPathWeight(amount: MilliSatoshi, length: Int, cltv: CltvExpiryDelta, successProbability: Double, fees: MilliSatoshi, virtualFees: MilliSatoshi, weight: Double) extends PathWeight { + case class PaymentPathWeight(amount: MilliSatoshi, length: Int, cltv: CltvExpiryDelta, successProbability: Double, fees: MilliSatoshi, virtualFees: MilliSatoshi, weight: Double, outboundFee: MilliSatoshi, feesForWeight: MilliSatoshi) extends PathWeight { override def canUseEdge(edge: GraphEdge): Boolean = amount <= edge.capacity && edge.balance_opt.forall(amount <= _) && @@ -58,7 +59,7 @@ object Graph { } object PaymentPathWeight { - def apply(amount: MilliSatoshi): PaymentPathWeight = PaymentPathWeight(amount, 0, CltvExpiryDelta(0), 1.0, 0 msat, 0 msat, 0.0) + def apply(amount: MilliSatoshi): PaymentPathWeight = PaymentPathWeight(amount, 0, CltvExpiryDelta(0), 1.0, 0 msat, 0 msat, 0.0, 0 msat, 0 msat) } /** @@ -84,8 +85,10 @@ object Graph { * @param prev weight of the rest of the path * @param currentBlockHeight the height of the chain tip (latest block). * @param includeLocalChannelCost if the path is for relaying and we need to include the cost of the local channel + * @param inbound_opt inbound fees + * @param enableInboundFees whether to include BLIP-18 inbound fees in weight calculation */ - def addEdgeWeight(sender: PublicKey, edge: GraphEdge, balance: BalanceEstimate, prev: RichWeight, currentBlockHeight: BlockHeight, includeLocalChannelCost: Boolean): RichWeight + def addEdgeWeight(sender: PublicKey, edge: GraphEdge, balance: BalanceEstimate, prev: RichWeight, currentBlockHeight: BlockHeight, includeLocalChannelCost: Boolean, inbound_opt: Option[Relayer.InboundFees], enableInboundFees: Boolean): RichWeight } /** @@ -99,8 +102,9 @@ object Graph { * @param usePastRelaysData use data from past relays to estimate the balance of the channels */ case class HeuristicsConstants(lockedFundsRisk: Double, failureFees: RelayFees, hopFees: RelayFees, useLogProbability: Boolean, usePastRelaysData: Boolean) extends WeightRatios[PaymentPathWeight] { - override def addEdgeWeight(sender: PublicKey, edge: GraphEdge, balance: BalanceEstimate, prev: PaymentPathWeight, currentBlockHeight: BlockHeight, includeLocalChannelCost: Boolean): PaymentPathWeight = { - val totalAmount = if (edge.desc.a == sender && !includeLocalChannelCost) prev.amount else addEdgeFees(edge, prev.amount) + override def addEdgeWeight(sender: PublicKey, edge: GraphEdge, balance: BalanceEstimate, prev: PaymentPathWeight, currentBlockHeight: BlockHeight, includeLocalChannelCost: Boolean, inbound_opt: Option[Relayer.InboundFees], enableInboundFees: Boolean): PaymentPathWeight = { + val isSenderEdge = edge.desc.a == sender && !includeLocalChannelCost + val (totalAmount, edgeOutboundFee) = addEdgeFeesWithInbound(edge, prev, inbound_opt, enableInboundFees, isSenderEdge) val fee = totalAmount - prev.amount val totalFees = prev.fees + fee val totalCltv = prev.cltv + edge.params.cltvExpiryDelta @@ -120,14 +124,16 @@ object Graph { } val totalSuccessProbability = prev.successProbability * successProbability val failureCost = nodeFee(failureFees, totalAmount) + val feeForWeight = math.max(0L, fee.toLong) + val totalFeesForWeight = prev.feesForWeight + fee val richWeight = if (useLogProbability) { val riskCost = totalAmount.toLong * edge.params.cltvExpiryDelta.toInt * lockedFundsRisk - val weight = prev.weight + fee.toLong + hopCost.toLong + riskCost - failureCost.toLong * math.log(successProbability) - PaymentPathWeight(totalAmount, prev.length + 1, totalCltv, totalSuccessProbability, totalFees, totalHopsCost, weight) + val weight = prev.weight + feeForWeight + hopCost.toLong + riskCost - failureCost.toLong * math.log(successProbability) + PaymentPathWeight(totalAmount, prev.length + 1, totalCltv, totalSuccessProbability, totalFees, totalHopsCost, weight, edgeOutboundFee, totalFeesForWeight) } else { val totalRiskCost = totalAmount.toLong * totalCltv.toInt * lockedFundsRisk - val weight = totalFees.toLong + totalHopsCost.toLong + totalRiskCost + failureCost.toLong / totalSuccessProbability - PaymentPathWeight(totalAmount, prev.length + 1, totalCltv, totalSuccessProbability, totalFees, totalHopsCost, weight) + val weight = totalFeesForWeight.toLong + totalHopsCost.toLong + totalRiskCost + failureCost.toLong / totalSuccessProbability + PaymentPathWeight(totalAmount, prev.length + 1, totalCltv, totalSuccessProbability, totalFees, totalHopsCost, weight, edgeOutboundFee, totalFeesForWeight) } if (edge.desc.a == sender) { // If this is a local channel it shouldn't add any weight. We always prefer local channels. @@ -144,7 +150,7 @@ object Graph { require(ageFactor >= 0.0, "ratio-channel-age must be nonnegative") require(capacityFactor >= 0.0, "ratio-channel-capacity must be nonnegative") - override def addEdgeWeight(sender: PublicKey, edge: GraphEdge, balance: BalanceEstimate, prev: MessagePathWeight, currentBlockHeight: BlockHeight, includeLocalChannelCost: Boolean): MessagePathWeight = { + override def addEdgeWeight(sender: PublicKey, edge: GraphEdge, balance: BalanceEstimate, prev: MessagePathWeight, currentBlockHeight: BlockHeight, includeLocalChannelCost: Boolean, inbound_opt: Option[Relayer.InboundFees], enableInboundFees: Boolean): MessagePathWeight = { import RoutingHeuristics._ // Every edge is weighted by funding block height where older blocks add less weight. The window considered is 1 year. @@ -214,10 +220,11 @@ object Graph { currentBlockHeight: BlockHeight, boundaries: PaymentPathWeight => Boolean, includeLocalChannelCost: Boolean, - excludePositiveInboundFees: Boolean = false): Seq[WeightedPath[PaymentPathWeight]] = { + excludePositiveInboundFees: Boolean = false, + blip18InboundFees: Boolean = false): Seq[WeightedPath[PaymentPathWeight]] = { // find the shortest path (k = 0) val targetWeight = PaymentPathWeight(amount) - dijkstraShortestPath(g, sourceNode, targetNode, ignoredEdges, ignoredVertices, extraEdges, targetWeight, boundaries, Features.empty, currentBlockHeight, wr, includeLocalChannelCost, excludePositiveInboundFees) match { + dijkstraShortestPath(g, sourceNode, targetNode, ignoredEdges, ignoredVertices, extraEdges, targetWeight, boundaries, Features.empty, currentBlockHeight, wr, includeLocalChannelCost, excludePositiveInboundFees, blip18InboundFees) match { case None => Seq.empty // if we can't even find a single path, avoid returning a Seq(Seq.empty) case Some(shortestPath) => @@ -228,7 +235,7 @@ object Graph { var allSpurPathsFound = false val shortestPaths = new mutable.Queue[PathWithSpur] - shortestPaths.enqueue(PathWithSpur(WeightedPath(shortestPath, pathWeight(g.balances, sourceNode, shortestPath, amount, currentBlockHeight, wr, includeLocalChannelCost)), 0)) + shortestPaths.enqueue(PathWithSpur(WeightedPath(shortestPath, pathWeight(g.balances, sourceNode, shortestPath, amount, currentBlockHeight, wr, includeLocalChannelCost, g.graph, blip18InboundFees)), 0)) // stores the candidates for the k-th shortest path, sorted by path cost val candidates = new mutable.PriorityQueue[PathWithSpur] @@ -253,12 +260,12 @@ object Graph { val alreadyExploredEdges = shortestPaths.collect { case p if p.p.path.takeRight(i) == rootPathEdges => p.p.path(p.p.path.length - 1 - i).desc }.toSet // we also want to ignore any vertex on the root path to prevent loops val alreadyExploredVertices = rootPathEdges.map(_.desc.b).toSet - val rootPathWeight = pathWeight(g.balances, sourceNode, rootPathEdges, amount, currentBlockHeight, wr, includeLocalChannelCost) + val rootPathWeight = pathWeight(g.balances, sourceNode, rootPathEdges, amount, currentBlockHeight, wr, includeLocalChannelCost, g.graph, blip18InboundFees) // find the "spur" path, a sub-path going from the spur node to the target avoiding previously found sub-paths - dijkstraShortestPath(g, sourceNode, spurNode, ignoredEdges ++ alreadyExploredEdges, ignoredVertices ++ alreadyExploredVertices, extraEdges, rootPathWeight, boundaries, Features.empty, currentBlockHeight, wr, includeLocalChannelCost, excludePositiveInboundFees) match { + dijkstraShortestPath(g, sourceNode, spurNode, ignoredEdges ++ alreadyExploredEdges, ignoredVertices ++ alreadyExploredVertices, extraEdges, rootPathWeight, boundaries, Features.empty, currentBlockHeight, wr, includeLocalChannelCost, excludePositiveInboundFees, blip18InboundFees) match { case Some(spurPath) => val completePath = spurPath ++ rootPathEdges - val candidatePath = WeightedPath(completePath, pathWeight(g.balances, sourceNode, completePath, amount, currentBlockHeight, wr, includeLocalChannelCost)) + val candidatePath = WeightedPath(completePath, pathWeight(g.balances, sourceNode, completePath, amount, currentBlockHeight, wr, includeLocalChannelCost, g.graph, blip18InboundFees)) candidates.enqueue(PathWithSpur(candidatePath, i)) case None => () } @@ -308,7 +315,8 @@ object Graph { currentBlockHeight: BlockHeight, wr: WeightRatios[RichWeight], includeLocalChannelCost: Boolean, - excludePositiveInboundFees: Boolean): Option[Seq[GraphEdge]] = { + excludePositiveInboundFees: Boolean, + enableInboundFees: Boolean): Option[Seq[GraphEdge]] = { // the graph does not contain source/destination nodes val sourceNotInGraph = !g.graph.containsVertex(sourceNode) && !extraEdges.exists(_.desc.a == sourceNode) val targetNotInGraph = !g.graph.containsVertex(targetNode) && !extraEdges.exists(_.desc.b == targetNode) @@ -351,17 +359,40 @@ object Graph { (neighbor == sourceNode || g.graph.getVertexFeatures(neighbor).areSupported(nodeFeatures))) { // 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 = wr.addEdgeWeight(sourceNode, edge, g.balances.get(edge), current.weight, currentBlockHeight, includeLocalChannelCost) + val inboundFees_opt = if (edge.desc.b == targetNode) None else getInboundFees(g.graph, edge) + val neighborWeight = wr.addEdgeWeight(sourceNode, edge, g.balances.get(edge), current.weight, currentBlockHeight, includeLocalChannelCost, inboundFees_opt, enableInboundFees) if (boundaries(neighborWeight)) { val previousNeighborWeight = bestWeights.get(neighbor) // if this path between neighbor and the target has a shorter distance than previously known, we select it if (previousNeighborWeight.forall(_.weight > neighborWeight.weight)) { - // update the best edge for this vertex - bestEdges.put(neighbor, edge) - // add this updated node to the list for further exploration - toExplore.enqueue(WeightedNode(neighbor, neighborWeight)) // O(1) - // update the minimum known distance array - bestWeights.put(neighbor, neighborWeight) + // Only check for cycles when neighbor is already settled (in visitedNodes). + // A settled node's bestEdges entry is final; if it chains back to current.key, + // setting bestEdges[neighbor]=edge would create a permanent cycle in path reconstruction. + // For unsettled neighbors the chain is still changing, so the check is too conservative. + val wouldCreateCycle = visitedNodes.contains(neighbor) && { + @tailrec + def reachesNeighbor(node: PublicKey): Boolean = { + if (node == targetNode) { + false + } else { + bestEdges.get(node) match { + case Some(e) if e.desc.b == neighbor => true + case Some(e) => reachesNeighbor(e.desc.b) + case None => false + } + } + } + + reachesNeighbor(current.key) + } + if (!wouldCreateCycle) { + // update the best edge for this vertex + bestEdges.put(neighbor, edge) + // add this updated node to the list for further exploration + toExplore.enqueue(WeightedNode(neighbor, neighborWeight)) // O(1) + // update the minimum known distance array + bestWeights.put(neighbor, neighborWeight) + } } } } @@ -372,9 +403,12 @@ object Graph { if (targetFound) { val edgePath = new mutable.ArrayBuffer[GraphEdge](RouteCalculation.ROUTE_MAX_LENGTH) var current = bestEdges.get(sourceNode) + var previousEdge: Option[GraphEdge] = None while (current.isDefined) { - edgePath += current.get - current = bestEdges.get(current.get.desc.b) + val edge = enrichEdgeWithInboundFees(current.get, previousEdge, g.graph, enableInboundFees) + edgePath += edge + previousEdge = Some(edge) + current = bestEdges.get(edge.desc.b) if (edgePath.length > RouteCalculation.ROUTE_MAX_LENGTH) { throw InfiniteLoop(edgePath.toSeq) } @@ -392,7 +426,7 @@ object Graph { boundaries: MessagePathWeight => Boolean, currentBlockHeight: BlockHeight, wr: MessageWeightRatios): Option[Seq[GraphEdge]] = - dijkstraShortestPath(g, sourceNode, targetNode, ignoredEdges = Set.empty, ignoredVertices, extraEdges = Set.empty, MessagePathWeight.zero, boundaries, Features(Features.OnionMessages -> FeatureSupport.Mandatory), currentBlockHeight, wr, includeLocalChannelCost = true, excludePositiveInboundFees = false) + dijkstraShortestPath(g, sourceNode, targetNode, ignoredEdges = Set.empty, ignoredVertices, extraEdges = Set.empty, MessagePathWeight.zero, boundaries, Features(Features.OnionMessages -> FeatureSupport.Mandatory), currentBlockHeight, wr, includeLocalChannelCost = true, excludePositiveInboundFees = false, enableInboundFees = false) /** * Find non-overlapping (no vertices shared) payment paths that support route blinding @@ -410,14 +444,15 @@ object Graph { wr: WeightRatios[PaymentPathWeight], currentBlockHeight: BlockHeight, boundaries: PaymentPathWeight => Boolean, - excludePositiveInboundFees: Boolean): Seq[WeightedPath[PaymentPathWeight]] = { + excludePositiveInboundFees: Boolean, + enableInboundFees: Boolean = false): Seq[WeightedPath[PaymentPathWeight]] = { val paths = new mutable.ArrayBuffer[WeightedPath[PaymentPathWeight]](pathsToFind) val verticesToIgnore = new mutable.HashSet[PublicKey]() verticesToIgnore.addAll(ignoredVertices) for (_ <- 1 to pathsToFind) { - dijkstraShortestPath(g, sourceNode, targetNode, ignoredEdges, verticesToIgnore.toSet, extraEdges = Set.empty, PaymentPathWeight(amount), boundaries, Features(Features.RouteBlinding -> FeatureSupport.Mandatory), currentBlockHeight, wr, includeLocalChannelCost = true, excludePositiveInboundFees) match { + dijkstraShortestPath(g, sourceNode, targetNode, ignoredEdges, verticesToIgnore.toSet, extraEdges = Set.empty, PaymentPathWeight(amount), boundaries, Features(Features.RouteBlinding -> FeatureSupport.Mandatory), currentBlockHeight, wr, includeLocalChannelCost = true, excludePositiveInboundFees, enableInboundFees) match { case Some(path) => - val weight = pathWeight(g.balances, sourceNode, path, amount, currentBlockHeight, wr, includeLocalChannelCost = true) + val weight = pathWeight(g.balances, sourceNode, path, amount, currentBlockHeight, wr, includeLocalChannelCost = true, g.graph, enableInboundFees) paths += WeightedPath(path, weight) // Additional paths must keep using the source and target nodes, but shouldn't use any of the same intermediate nodes. verticesToIgnore.addAll(path.drop(1).map(_.desc.a)) @@ -427,6 +462,44 @@ object Graph { paths.toSeq } + /** + * Enriches an edge with inbound fees from the previous edge's back-edge. + * This is used during path reconstruction to apply BLIP-18 inbound fees. + * + * @param edge the current edge to potentially enrich + * @param previousEdge_opt the previous edge in the path (None for the first edge) + * @param g the graph structure containing back-edges + * @param enableInboundFees whether to enrich with inbound fees + * @return the edge, potentially enriched with inbound fees + */ + private def enrichEdgeWithInboundFees(edge: GraphEdge, previousEdge_opt: Option[GraphEdge], g: GraphStructure.DirectedGraph, enableInboundFees: Boolean): GraphEdge = { + if (!enableInboundFees) { + edge + } else { + previousEdge_opt match { + case Some(prevEdge) => + edge.params match { + case params: HopRelayParams.FromAnnouncement if params.inboundFees_opt.isEmpty => + // Look up the previous edge's back-edge and extract its BLIP18 fees + val inboundFees_opt = g.getBackEdge(prevEdge.desc) + .flatMap(_.getChannelUpdate) + .flatMap(_.blip18InboundFees_opt) + if (inboundFees_opt.isDefined) { + edge.copy(params = params.copy(inboundFees_opt = inboundFees_opt)) + } else { + edge + } + case _ => edge + } + case None => edge // First edge has no inbound fees + } + } + } + + private def getInboundFees(graph: GraphStructure.DirectedGraph, edge: GraphEdge): Option[Relayer.InboundFees] = graph.getBackEdge(edge.desc) + .flatMap(_.getChannelUpdate) + .flatMap(_.blip18InboundFees_opt) + /** * Calculate the minimum amount that the start node needs to receive to be able to forward @amountWithFees to the end * node. @@ -439,6 +512,54 @@ object Graph { amountToForward + edge.params.fee(amountToForward) } + /** + * Calculate the minimum amount that the start node needs to send to forward to the end node, + * including both outbound and inbound fees (BLIP-18), following LND's approach. + * + * During backward traversal, when considering edge A→B: + * - Inbound fee: charged by B for receiving from A (from B→A channel_update's BLIP-18 TLV), + * calculated on prev.amount (what B receives), capped so that B's total node fee >= 0 + * - Outbound fee: charged by A for forwarding (from A→B channel_update), + * calculated on (prev.amount + capped inbound fee) + * + * @param edge the edge we want to cross (A→B) + * @param prev weight of the rest of the path (from B toward target) + * @param inbound_opt B's inbound fees for traffic from A + * @param enableInboundFees whether to include BLIP-18 inbound fees + * @param isSenderEdge true if A is the payment sender (no outbound fee charged) + * @return (totalAmount, outboundFee) where totalAmount is what A needs to receive and + * outboundFee is A's outbound fee (stored for next iteration's inbound fee capping) + */ + private def addEdgeFeesWithInbound(edge: GraphEdge, prev: PaymentPathWeight, inbound_opt: Option[Relayer.InboundFees], enableInboundFees: Boolean, isSenderEdge: Boolean): (MilliSatoshi, MilliSatoshi) = { + if (!enableInboundFees) { + if (isSenderEdge) { + (prev.amount, 0 msat) + } else { + val outboundFee = nodeFee(edge.params.relayFees, prev.amount) + (prev.amount + outboundFee, outboundFee) + } + } else { + val inboundFee = inbound_opt + .map(inbound => nodeFee(inbound.feeBase, inbound.feeProportionalMillionths, prev.amount)) + .getOrElse(0 msat) + + val cappedInboundFee = if (inboundFee.toLong < -prev.outboundFee.toLong) { + MilliSatoshi(-prev.outboundFee.toLong) + } else { + inboundFee + } + + val amountToSend = prev.amount + cappedInboundFee + + if (isSenderEdge) { + (amountToSend, 0 msat) + } else { + val outboundFee = nodeFee(edge.params.relayFees, amountToSend) + (amountToSend + outboundFee, outboundFee) + } + } + } + /** Validate that all edges along the path can relay the amount with fees. */ def validatePath(path: Seq[GraphEdge], amount: MilliSatoshi): Boolean = validateReversePath(path.reverse, amount) @@ -463,10 +584,15 @@ object Graph { * @param currentBlockHeight the height of the chain tip (latest block). * @param wr ratios used to 'weight' edges when searching for the shortest path * @param includeLocalChannelCost if the path is for relaying and we need to include the cost of the local channel + * @param graph the graph structure for back-edge lookups (used for inbound fees) + * @param enableInboundFees whether to include BLIP-18 inbound fees in weight calculation */ - def pathWeight(balances: BalancesEstimates, sender: PublicKey, path: Seq[GraphEdge], amount: MilliSatoshi, currentBlockHeight: BlockHeight, wr: WeightRatios[PaymentPathWeight], includeLocalChannelCost: Boolean): PaymentPathWeight = { + def pathWeight(balances: BalancesEstimates, sender: PublicKey, path: Seq[GraphEdge], amount: MilliSatoshi, currentBlockHeight: BlockHeight, wr: WeightRatios[PaymentPathWeight], includeLocalChannelCost: Boolean, graph: GraphStructure.DirectedGraph, enableInboundFees: Boolean): PaymentPathWeight = { + if (path.isEmpty) return PaymentPathWeight(amount) + val targetNode = path.last.desc.b path.foldRight(PaymentPathWeight(amount)) { (edge, prev) => - wr.addEdgeWeight(sender, edge, balances.get(edge), prev, currentBlockHeight, includeLocalChannelCost) + val inboundFees_opt = if (edge.desc.b == targetNode) None else getInboundFees(graph, edge) + wr.addEdgeWeight(sender, edge, balances.get(edge), prev, currentBlockHeight, includeLocalChannelCost, inboundFees_opt, enableInboundFees) } } 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 d5250313d4..140f186aab 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 @@ -65,6 +65,29 @@ object RouteCalculation { } } + def enrichRouteWithInboundFees(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) + } + } + Logs.withMdc(log)(Logs.mdc( category_opt = Some(LogCategory.PAYMENT), parentPaymentId_opt = fr.paymentContext.map(_.parentId), @@ -84,7 +107,7 @@ object RouteCalculation { 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)) val route = if (fr.blip18InboundFees) { - validatePositiveInboundFees(routeWithInboundFees(amount, hops, g), fr.excludePositiveInboundFees) + validatePositiveInboundFees(enrichRouteWithInboundFees(amount, hops, g), fr.excludePositiveInboundFees) } else { Success(Route(amount, hops, None)) } @@ -125,7 +148,7 @@ object RouteCalculation { fr.replyTo ! PaymentRouteNotFound(new IllegalArgumentException("The sequence of channels provided cannot be used to build a route to the target node")) } else { val route = if (fr.blip18InboundFees) { - validatePositiveInboundFees(routeWithInboundFees(amount, hops, g), fr.excludePositiveInboundFees) + validatePositiveInboundFees(enrichRouteWithInboundFees(amount, hops, g), fr.excludePositiveInboundFees) } else { Success(Route(amount, hops, None)) } @@ -340,40 +363,14 @@ object RouteCalculation { blip18InboundFees: Boolean = false, excludePositiveInboundFees: Boolean = false, ): Try[Seq[Route]] = Try { - findRouteInternal(g, localNodeId, targetNodeId, amount, maxFee, numRoutes, extraEdges, ignoredEdges, ignoredVertices, routeParams, currentBlockHeight, excludePositiveInboundFees) match { + findRouteInternal(g, localNodeId, targetNodeId, amount, maxFee, numRoutes, extraEdges, ignoredEdges, ignoredVertices, routeParams, currentBlockHeight, excludePositiveInboundFees, blip18InboundFees) match { case Right(routes) => routes.map { route => - if (blip18InboundFees) - routeWithInboundFees(amount, route.path.map(graphEdgeToHop), g.graph) - else - Route(amount, route.path.map(graphEdgeToHop), None) + 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: GraphWithBalanceEstimates, localNodeId: PublicKey, @@ -386,7 +383,8 @@ object RouteCalculation { ignoredVertices: Set[PublicKey] = Set.empty, routeParams: RouteParams, currentBlockHeight: BlockHeight, - excludePositiveInboundFees: Boolean): Either[RouterException, Seq[WeightedPath[PaymentPathWeight]]] = { + excludePositiveInboundFees: Boolean, + enableInboundFees: Boolean = false): Either[RouterException, Seq[WeightedPath[PaymentPathWeight]]] = { require(amount > 0.msat, "route amount must be strictly positive") @@ -400,7 +398,7 @@ object RouteCalculation { val boundaries: PaymentPathWeight => Boolean = { weight => feeOk(weight.amount - amount) && lengthOk(weight.length) && cltvOk(weight.cltv) } - val foundRoutes: Seq[WeightedPath[PaymentPathWeight]] = Graph.yenKshortestPaths(g, localNodeId, targetNodeId, amount, ignoredEdges, ignoredVertices, extraEdges, numRoutes, routeParams.heuristics, currentBlockHeight, boundaries, routeParams.includeLocalChannelCost, excludePositiveInboundFees) + val foundRoutes: Seq[WeightedPath[PaymentPathWeight]] = Graph.yenKshortestPaths(g, localNodeId, targetNodeId, amount, ignoredEdges, ignoredVertices, extraEdges, numRoutes, routeParams.heuristics, currentBlockHeight, boundaries, routeParams.includeLocalChannelCost, excludePositiveInboundFees, enableInboundFees) if (foundRoutes.nonEmpty) { val (directRoutes, indirectRoutes) = foundRoutes.partition(_.path.length == 1) val routes = if (routeParams.randomize) { @@ -417,7 +415,7 @@ object RouteCalculation { maxCltv = DEFAULT_ROUTE_MAX_CLTV, ) ) - findRouteInternal(g, localNodeId, targetNodeId, amount, maxFee, numRoutes, extraEdges, ignoredEdges, ignoredVertices, relaxedRouteParams, currentBlockHeight, excludePositiveInboundFees) + findRouteInternal(g, localNodeId, targetNodeId, amount, maxFee, numRoutes, extraEdges, ignoredEdges, ignoredVertices, relaxedRouteParams, currentBlockHeight, excludePositiveInboundFees, enableInboundFees) } else { Left(RouteNotFound) } @@ -488,24 +486,18 @@ object RouteCalculation { val minPartAmount = routeParams.mpp.minPartAmount.max(amount / numRoutes).min(amount) 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, excludePositiveInboundFees) match { + findRouteInternal(g, localNodeId, targetNodeId, routeParams1.mpp.minPartAmount, maxFee, routeParams1.mpp.maxParts, extraEdges, ignoredEdges, ignoredVertices, routeParams1, currentBlockHeight, excludePositiveInboundFees, blip18InboundFees) match { 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(paths: _*), initializeUsedCapacity(pendingHtlcs), routeParams1, g.balances, now) match { case Right(routes) if validateMultiPartRoute(amount, maxFee, routes, routeParams.includeLocalChannelCost) => - if (blip18InboundFees) - Right(routes.map(r => routeWithInboundFees(r.amount, r.hops, g.graph))) - else - Right(routes) + 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) => - if (blip18InboundFees) - Right(routes.map(r => routeWithInboundFees(r.amount, r.hops, g.graph))) - else - Right(routes) + Right(routes) case _ => Left(RouteNotFound) } case _ => Left(RouteNotFound) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/router/Blip18FinalizeRouteSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/router/Blip18FinalizeRouteSpec.scala new file mode 100644 index 0000000000..d2d7310596 --- /dev/null +++ b/eclair-core/src/test/scala/fr/acinq/eclair/router/Blip18FinalizeRouteSpec.scala @@ -0,0 +1,393 @@ +package fr.acinq.eclair.router + +import akka.actor.typed.scaladsl.adapter.ClassicActorRefOps +import akka.event.DiagnosticLoggingAdapter +import akka.testkit.TestProbe +import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey +import fr.acinq.bitcoin.scalacompat.{Block, Satoshi, SatoshiLong} +import fr.acinq.eclair.payment.relay.Relayer.RelayFees +import fr.acinq.eclair.router.Graph.GraphStructure.{DirectedGraph, GraphEdge} +import fr.acinq.eclair.router.Graph.HeuristicsConstants +import fr.acinq.eclair.router.Router.MultiPartParams.FullCapacity +import fr.acinq.eclair.router.Router._ +import fr.acinq.eclair.transactions.Transactions +import fr.acinq.eclair.wire.protocol._ +import fr.acinq.eclair.{CltvExpiryDelta, Features, MilliSatoshi, MilliSatoshiLong, RealShortChannelId, ShortChannelId, TestKitBaseClass, TimestampSecond, TimestampSecondLong, randomKey} +import org.scalatest.ParallelTestExecution +import org.scalatest.funsuite.AnyFunSuiteLike + +import scala.collection.immutable.SortedMap +import scala.concurrent.duration.DurationInt + +class Blip18FinalizeRouteSpec extends TestKitBaseClass with AnyFunSuiteLike with ParallelTestExecution { + + import Blip18FinalizeRouteSpec._ + + val (priv_a, priv_b, priv_c, priv_d, priv_e) = (randomKey(), randomKey(), randomKey(), randomKey(), randomKey()) + val (a, b, c, d, e) = (priv_a.publicKey, priv_b.publicKey, priv_c.publicKey, priv_d.publicKey, priv_e.publicKey) + + // Create a test actor to get ActorContext + class DummyActor extends akka.actor.Actor { + def receive: Receive = { case _ => } + } + val dummyActor = system.actorOf(akka.actor.Props(new DummyActor)) + val dummyRef = akka.testkit.TestActorRef(new DummyActor) + implicit val dummyContext: akka.actor.ActorContext = dummyRef.underlyingActor.context + implicit val dummyLog: DiagnosticLoggingAdapter = new DiagnosticLoggingAdapter { + override def isErrorEnabled: Boolean = false + + override def isWarningEnabled: Boolean = false + + override def isInfoEnabled: Boolean = false + + override def isDebugEnabled: Boolean = false + + override protected def notifyError(message: String): Unit = () + + override protected def notifyError(cause: Throwable, message: String): Unit = () + + override protected def notifyWarning(message: String): Unit = () + + override protected def notifyInfo(message: String): Unit = () + + override protected def notifyDebug(message: String): Unit = () + } + + test("finalizeRoute with PredefinedNodeRoute and BLIP-18 enabled") { + 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)), + )) + + val data = Router.Data( + nodes = Map.empty, + channels = makeChannelsFromGraph(g), + prunedChannels = collection.immutable.SortedMap.empty, + stash = Router.Stash(Map.empty, Map.empty), + rebroadcast = Router.Rebroadcast(Map.empty, Map.empty, Map.empty), + awaiting = Map.empty, + privateChannels = Map.empty, + scid2PrivateChannels = Map.empty, + excludedChannels = Map.empty, + graphWithBalances = GraphWithBalanceEstimates(g, 1 day), + sync = Map.empty, + spentChannels = Map.empty + ) + + val route = PredefinedNodeRoute(100_000 msat, Seq(a, b, c, d)) + val probe = TestProbe() + val replyTo = probe.ref.toTyped[Router.PaymentRouteResponse] + val fr = Router.FinalizeRoute(replyTo, route, blip18InboundFees = true, excludePositiveInboundFees = false) + + + RouteCalculation.finalizeRoute(data, a, fr) + + probe.expectMsgType[Router.RouteResponse] match { + case Router.RouteResponse(routes) => + assert(routes.length == 1) + val r = routes.head + assert(r.hops.length == 3) + // Check that inbound fees are enriched from back-edges + assert(r.hops(0).params.inboundFees_opt.isEmpty) // First hop has no inbound fees + assert(r.hops(1).params.inboundFees_opt.isDefined) // Second hop should have inbound fees from b->a + assert(r.hops(1).params.inboundFees_opt.get.feeBase == -5000.msat) + assert(r.hops(1).params.inboundFees_opt.get.feeProportionalMillionths == -60_000) + assert(r.hops(2).params.inboundFees_opt.isDefined) // Third hop should have inbound fees from c->b + assert(r.hops(2).params.inboundFees_opt.get.feeBase == 5000.msat) + assert(r.hops(2).params.inboundFees_opt.get.feeProportionalMillionths == 0) + } + } + + test("finalizeRoute with PredefinedNodeRoute and BLIP-18 disabled") { + 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)), + )) + + val data = Router.Data( + nodes = Map.empty, + channels = makeChannelsFromGraph(g), + prunedChannels = collection.immutable.SortedMap.empty, + stash = Router.Stash(Map.empty, Map.empty), + rebroadcast = Router.Rebroadcast(Map.empty, Map.empty, Map.empty), + awaiting = Map.empty, + privateChannels = Map.empty, + scid2PrivateChannels = Map.empty, + excludedChannels = Map.empty, + graphWithBalances = GraphWithBalanceEstimates(g, 1 day), + sync = Map.empty, + spentChannels = Map.empty + ) + + val route = PredefinedNodeRoute(100_000 msat, Seq(a, b, c, d)) + val probe = TestProbe() + val replyTo = probe.ref.toTyped[Router.PaymentRouteResponse] + val fr = Router.FinalizeRoute(replyTo, route, blip18InboundFees = false, excludePositiveInboundFees = false) + + RouteCalculation.finalizeRoute(data, a, fr) + + probe.expectMsgType[Router.RouteResponse] match { + case Router.RouteResponse(routes) => + assert(routes.length == 1) + val r = routes.head + assert(r.hops.length == 3) + // Check that no inbound fees are enriched when BLIP-18 is disabled + assert(r.hops(0).params.inboundFees_opt.isEmpty) + assert(r.hops(1).params.inboundFees_opt.isEmpty) + assert(r.hops(2).params.inboundFees_opt.isEmpty) + } + } + + test("finalizeRoute with PredefinedChannelRoute and BLIP-18 enabled") { + 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 data = Router.Data( + nodes = Map.empty, + channels = makeChannelsFromGraph(g), + prunedChannels = collection.immutable.SortedMap.empty, + stash = Router.Stash(Map.empty, Map.empty), + rebroadcast = Router.Rebroadcast(Map.empty, Map.empty, Map.empty), + awaiting = Map.empty, + privateChannels = Map.empty, + scid2PrivateChannels = Map.empty, + excludedChannels = Map.empty, + graphWithBalances = GraphWithBalanceEstimates(g, 1 day), + sync = Map.empty, + spentChannels = Map.empty + ) + + val route = PredefinedChannelRoute(100_000 msat, e, Seq(ShortChannelId(10L), ShortChannelId(11L), ShortChannelId(12L), ShortChannelId(13L))) + val probe = TestProbe() + val replyTo = probe.ref.toTyped[Router.PaymentRouteResponse] + val fr = Router.FinalizeRoute(replyTo, route, blip18InboundFees = true, excludePositiveInboundFees = false) + + RouteCalculation.finalizeRoute(data, a, fr) + + probe.expectMsgType[Router.RouteResponse] match { + case Router.RouteResponse(routes) => + assert(routes.length == 1) + val r = routes.head + assert(r.hops.length == 4) + // Check that inbound fees are enriched from back-edges + assert(r.hops(0).params.inboundFees_opt.isEmpty) // First hop has no inbound fees + assert(r.hops(1).params.inboundFees_opt.isDefined) // Second hop should have inbound fees from b->a + assert(r.hops(1).params.inboundFees_opt.get.feeBase == -5000.msat) + assert(r.hops(1).params.inboundFees_opt.get.feeProportionalMillionths == -60_000) + assert(r.hops(2).params.inboundFees_opt.isDefined) // Third hop should have inbound fees from c->b + assert(r.hops(2).params.inboundFees_opt.get.feeBase == 5000.msat) + assert(r.hops(2).params.inboundFees_opt.get.feeProportionalMillionths == 0) + assert(r.hops(3).params.inboundFees_opt.isDefined) // Third hop should have inbound fees from c->b + assert(r.hops(3).params.inboundFees_opt.get.feeBase == -8000.msat) + assert(r.hops(3).params.inboundFees_opt.get.feeProportionalMillionths == -50_000) + assert(r.amount == 100000.msat) + assert(r.channelFee(true) == 15302.msat) + } + } + + test("finalizeRoute with PredefinedChannelRoute and BLIP-18 disabled") { + 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 data = Router.Data( + nodes = Map.empty, + channels = makeChannelsFromGraph(g), + prunedChannels = collection.immutable.SortedMap.empty, + stash = Router.Stash(Map.empty, Map.empty), + rebroadcast = Router.Rebroadcast(Map.empty, Map.empty, Map.empty), + awaiting = Map.empty, + privateChannels = Map.empty, + scid2PrivateChannels = Map.empty, + excludedChannels = Map.empty, + graphWithBalances = GraphWithBalanceEstimates(g, 1 day), + sync = Map.empty, + spentChannels = Map.empty + ) + + val route = PredefinedChannelRoute(100_000 msat, e, Seq(ShortChannelId(10L), ShortChannelId(11L), ShortChannelId(12L), ShortChannelId(13L))) + val probe = TestProbe() + val replyTo = probe.ref.toTyped[Router.PaymentRouteResponse] + val fr = Router.FinalizeRoute(replyTo, route, blip18InboundFees = false, excludePositiveInboundFees = false) + + RouteCalculation.finalizeRoute(data, a, fr) + + probe.expectMsgType[Router.RouteResponse] match { + case Router.RouteResponse(routes) => + assert(routes.length == 1) + val r = routes.head + assert(r.hops.length == 4) + // Check that no inbound fees are enriched when BLIP-18 is disabled + assert(r.hops(0).params.inboundFees_opt.isEmpty) + assert(r.hops(1).params.inboundFees_opt.isEmpty) + assert(r.hops(2).params.inboundFees_opt.isEmpty) + assert(r.hops(3).params.inboundFees_opt.isEmpty) + assert(r.amount == 100000.msat) + assert(r.channelFee(true) == 32197.msat) + } + } + + test("finalizeRoute with positive inbound fees should fail when excludePositiveInboundFees is true") { + 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(100)), + makeEdge(11L, b, c, 1000 msat, 50_000, minHtlc = 2 msat), + makeEdge(11L, c, b, minHtlc = 2 msat, inboundFeeBase_opt = Some(0 msat), inboundFeeProportionalMillionth_opt = Some(0)), + )) + + val data = Router.Data( + nodes = Map.empty, + channels = makeChannelsFromGraph(g), + prunedChannels = collection.immutable.SortedMap.empty, + stash = Router.Stash(Map.empty, Map.empty), + rebroadcast = Router.Rebroadcast(Map.empty, Map.empty, Map.empty), + awaiting = Map.empty, + privateChannels = Map.empty, + scid2PrivateChannels = Map.empty, + excludedChannels = Map.empty, + graphWithBalances = GraphWithBalanceEstimates(g, 1 day), + sync = Map.empty, + spentChannels = Map.empty + ) + + val route = PredefinedNodeRoute(100_000 msat, Seq(a, b, c)) + val probe = TestProbe() + val replyTo = probe.ref.toTyped[Router.PaymentRouteResponse] + val fr = Router.FinalizeRoute(replyTo, route, blip18InboundFees = true, excludePositiveInboundFees = true) + + RouteCalculation.finalizeRoute(data, a, fr) + + probe.expectMsgType[Router.PaymentRouteNotFound] match { + case Router.PaymentRouteNotFound(reason) => + assert(reason.getMessage.contains("positive inbound fees")) + } + } + +} + +object Blip18FinalizeRouteSpec { + + val DEFAULT_AMOUNT_MSAT: MilliSatoshi = 10_000_000 msat + val DEFAULT_CAPACITY: Satoshi = 100_000 sat + + val NO_WEIGHT_RATIOS: HeuristicsConstants = HeuristicsConstants(0, RelayFees(0 msat, 0), RelayFees(0 msat, 0), useLogProbability = false, usePastRelaysData = false) + val DEFAULT_ROUTE_PARAMS: Router.RouteParams = PathFindingConf( + randomize = false, + boundaries = SearchBoundaries(21000 msat, 0.03, 6, CltvExpiryDelta(2016)), + NO_WEIGHT_RATIOS, + MultiPartParams(1000 msat, 10, FullCapacity), + experimentName = "my-test-experiment", + experimentPercentage = 100).getDefaultRouteParams + + val DUMMY_SIG: fr.acinq.bitcoin.scalacompat.ByteVector64 = 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 makeChannelsFromGraph(g: DirectedGraph): SortedMap[RealShortChannelId, Router.PublicChannel] = { + // Group edges by channel ID + val edgesByChannel = g.edgeSet().groupBy(_.desc.shortChannelId) + + SortedMap.from(edgesByChannel.map { case (scid, edges) => + // Get the two edges for this channel (forward and backward) + val edgeList = edges.toList + require(edgeList.length == 2, s"Expected 2 edges for channel $scid, got ${edgeList.length}") + + val edge1 = edgeList.head + val edge2 = edgeList(1) + + // Create channel announcement (makeChannel will determine correct node1/node2 ordering) + val ann = makeChannel(scid.toLong, edge1.desc.a, edge1.desc.b) + + // Extract channel updates from edges + val update1 = edge1.params match { + case HopRelayParams.FromAnnouncement(u, _) => u + case _ => throw new IllegalArgumentException("Expected FromAnnouncement params") + } + val update2 = edge2.params match { + case HopRelayParams.FromAnnouncement(u, _) => u + case _ => throw new IllegalArgumentException("Expected FromAnnouncement params") + } + + // Determine which update goes to which side based on isNode1 flag + val (update_1_opt, update_2_opt) = if (update1.channelFlags.isNode1) { + (Some(update1), Some(update2)) + } else { + (Some(update2), Some(update1)) + } + + val rscid = RealShortChannelId(scid.toLong) + + rscid -> Router.PublicChannel(ann, fr.acinq.bitcoin.scalacompat.TxId(fr.acinq.bitcoin.scalacompat.ByteVector32.Zeroes), DEFAULT_CAPACITY, update_1_opt, update_2_opt, None) + }) + } + + 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 + ) + } +} \ No newline at end of file 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 51b49259a7..23dd9f73f4 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 @@ -1,33 +1,50 @@ package fr.acinq.eclair.router -import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey -import fr.acinq.bitcoin.scalacompat.{Block, Satoshi, SatoshiLong} +import com.softwaremill.quicklens.ModifyPimp +import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} +import fr.acinq.bitcoin.scalacompat.{Block, ByteVector32, Satoshi, SatoshiLong, TxId} 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.reputation.Reputation +import fr.acinq.eclair.router.Announcements.makeNodeAnnouncement +import fr.acinq.eclair.router.BaseRouterSpec.channelHopFromUpdate +import fr.acinq.eclair.router.Graph.GraphStructure.DirectedGraph.graphEdgeToHop import fr.acinq.eclair.router.Graph.GraphStructure.{DirectedGraph, GraphEdge} -import fr.acinq.eclair.router.Graph.HeuristicsConstants -import fr.acinq.eclair.router.RouteCalculation.{findMultiPartRoute, findRoute} -import fr.acinq.eclair.router.Router.MultiPartParams.FullCapacity +import fr.acinq.eclair.router.Graph.{HeuristicsConstants, PaymentPathWeight} +import fr.acinq.eclair.router.RouteCalculation._ +import fr.acinq.eclair.router.Router.MultiPartParams.{FullCapacity, MaxExpectedAmount, Randomize} 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 fr.acinq.eclair.{BlockHeight, CltvExpiry, CltvExpiryDelta, Features, MilliSatoshi, MilliSatoshiLong, RealShortChannelId, ShortChannelId, TestConstants, TimestampSecond, TimestampSecondLong, ToMilliSatoshiConversion, randomBytes32, randomKey} +import org.scalatest.TryValues.convertTryToSuccessOrFailure import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.{ParallelTestExecution, Tag} +import scodec.bits._ +import scala.collection.mutable import scala.concurrent.duration.DurationInt -import scala.util.{Failure, Success} +import scala.util.{Failure, Random, 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()) + implicit val log: akka.event.LoggingAdapter = akka.event.NoLogging + + val (priv_a, priv_b, priv_c, priv_d, priv_e, priv_f) = ( + PrivateKey(hex"a5fd7d10b2756c8415d22c0bc177a7ee2ce01fc0834f0962aa2a2314f5df8310"), + PrivateKey(hex"9b6ce29f39ebb0be0d8dfdd6fcd32c52bb0b66b7df7ece9b882428d7eb39f3d6"), + PrivateKey(hex"e398f98ec5949f6d59da72efc2418a602d8186d2107987c3478445cb62e5dd9e"), + PrivateKey(hex"74cb9dde6bf4e983e442241c1f1fc6387af824c45b781d94e43e4dbb8f87f1eb"), + PrivateKey(hex"7423aa471808b212fd1ca5796a4acba1eb99a9091056c6a3c26ff0ad1a1c0282"), + PrivateKey(hex"244ddf51ca4cc0055a0368ec8676822546bf207436c41ec58046398712c9e627"), + ) + 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") { @@ -146,237 +163,2464 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution assert(payload_e.totalAmount == 100_000.msat) } - test("test findMultiPartRoute with Blip18 enabled") { - // extracted from the LND code base + test("test findRoute selects path with negative inbound fees when BLIP-18 enabled") { + // Two paths from a to e: + // Path 1: a -> b -> c -> d -> e + // Path 2: a -> f -> e val g = GraphWithBalanceEstimates(DirectedGraph(Seq( + // Path 1: a -> b -> c -> d -> e (with inbound fees) 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)), + // Path 2: a -> f -> e (no inbound fees) + makeEdge(30L, a, f, minHtlc = 2 msat), + makeEdge(30L, f, a, minHtlc = 2 msat), + makeEdge(31L, f, e, 15000 msat, 100_000, minHtlc = 2 msat), + makeEdge(31L, e, f, minHtlc = 2 msat), )), 1 day) - val Success(route :: Nil) = findMultiPartRoute(g, a, e, 100_000 msat, 100_000 msat, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true, excludePositiveInboundFees = false) + val Success(route :: Nil) = findRoute(g, a, e, 100_000 msat, 100_000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true, excludePositiveInboundFees = false) - assert(route.channelFee(false) == 15_302.msat) + // With BLIP-18, path 1 is cheaper thanks to negative inbound fee discounts + assert(route.hops.length == 4) + 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, Reputation.Score.max) + val recipient = ClearRecipient(e, Features.empty, 100_000 msat, CltvExpiry(400018), paymentSecret) + val Right(payment) = buildOutgoingPayment(TestConstants.emptyOrigin, paymentHash, route, recipient, Reputation.Score.max) - assert(payment.outgoingChannel == ShortChannelId(10L)) - assert(payment.cmd.amount == 115_302.msat) + assert(payment.outgoingChannel == ShortChannelId(10L)) + assert(payment.cmd.amount == 115_302.msat) - val packet_b = payment.cmd.onion + val packet_b = payment.cmd.onion - val add_b = UpdateAddHtlc(randomBytes32(), 0, 100_000 msat, paymentHash, CltvExpiry(400018), packet_b, None, 1, 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_b = UpdateAddHtlc(randomBytes32(), 0, 100_000 msat, paymentHash, CltvExpiry(400018), packet_b, None, 1, 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, 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_c = UpdateAddHtlc(randomBytes32(), 1, 100_000 msat, paymentHash, CltvExpiry(400018), packet_c, None, 1, 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, 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_d = UpdateAddHtlc(randomBytes32(), 2, 100_000 msat, paymentHash, CltvExpiry(400018), packet_d, None, 1, 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, 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) - } + val add_e = UpdateAddHtlc(randomBytes32(), 3, 100_000 msat, paymentHash, CltvExpiry(400018), packet_e, None, 1, 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 = GraphWithBalanceEstimates(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)), + test("test findRoute selects path without inbound fees when BLIP-18 disabled") { + // Same graph as above. Without BLIP-18, inbound discounts are ignored and path 2 (a -> f -> e) wins. + val g = GraphWithBalanceEstimates(DirectedGraph(Seq( + // Path 1: a -> b -> c -> d -> e (inbound fees ignored, total outbound fee = 32_197) + 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)), + // Path 2: a -> f -> e (no inbound fees, outbound fee = 25_000) + makeEdge(30L, a, f, minHtlc = 2 msat), + makeEdge(30L, f, a, minHtlc = 2 msat), + makeEdge(31L, f, e, 15000 msat, 100_000, minHtlc = 2 msat), + makeEdge(31L, e, f, minHtlc = 2 msat), + )), 1 day) - 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)), + val Success(route :: Nil) = findRoute(g, a, e, 100_000 msat, 100_000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = false, excludePositiveInboundFees = false) - 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)), + // Without BLIP-18, path 2 is cheaper based on outbound fees alone + assert(route.hops.length == 2) + assert(route.channelFee(false) == 25_000.msat) - 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)), - )), 1 day) + val recipient = ClearRecipient(e, Features.empty, 100_000 msat, CltvExpiry(400018), paymentSecret) + val Right(payment) = buildOutgoingPayment(TestConstants.emptyOrigin, paymentHash, route, recipient, Reputation.Score.max) - val Success(route :: Nil) = findMultiPartRoute(g, a, e, 100_000 msat, 100_000 msat, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = false, excludePositiveInboundFees = false) + assert(payment.outgoingChannel == ShortChannelId(30L)) + assert(payment.cmd.amount == 125_000.msat) - assert(route.channelFee(false) == 32_197.msat) + val packet_f = payment.cmd.onion - val recipient = ClearRecipient(e, Features.empty, 100_000 msat, CltvExpiry(400018), paymentSecret) - val Right(payment) = buildOutgoingPayment(TestConstants.emptyOrigin, paymentHash, route, recipient, Reputation.Score.max) + val add_f = UpdateAddHtlc(randomBytes32(), 0, 100_000 msat, paymentHash, CltvExpiry(400018), packet_f, None, 1, None) + val Right(relay_f@ChannelRelayPacket(_, payload_f, packet_e, _)) = decrypt(add_f, priv_f, Features.empty) + assert(payload_f.outgoing.contains(ShortChannelId(31L))) + assert(relay_f.amountToForward == 100_000.msat) + assert(relay_f.relayFeeMsat == 0.msat) - assert(payment.outgoingChannel == ShortChannelId(10L)) - assert(payment.cmd.amount == 132_197.msat) + val add_e = UpdateAddHtlc(randomBytes32(), 1, 100_000 msat, paymentHash, CltvExpiry(400018), packet_e, None, 1, 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) + } - val packet_b = payment.cmd.onion - val add_b = UpdateAddHtlc(randomBytes32(), 0, 100_000 msat, paymentHash, CltvExpiry(400018), packet_b, None, 1, 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) + test("test findMultiPartRoute with Blip18 enabled") { + // extracted from the LND code base + val g = GraphWithBalanceEstimates(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)), - val add_c = UpdateAddHtlc(randomBytes32(), 1, 100_000 msat, paymentHash, CltvExpiry(400018), packet_c, None, 1, 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) + 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)), - val add_d = UpdateAddHtlc(randomBytes32(), 2, 100_000 msat, paymentHash, CltvExpiry(400018), packet_d, None, 1, 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) + 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)), - val add_e = UpdateAddHtlc(randomBytes32(), 2, 100_000 msat, paymentHash, CltvExpiry(400018), packet_e, None, 1, 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) - } + 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)), + )), 1 day) + + val Success(route :: Nil) = findMultiPartRoute(g, a, e, 100_000 msat, 100_000 msat, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true, excludePositiveInboundFees = false) + + 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, Reputation.Score.max) + + 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, 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, 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, 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, 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 = GraphWithBalanceEstimates(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)), + )), 1 day) + + val Success(route :: Nil) = findMultiPartRoute(g, a, e, 100_000 msat, 100_000 msat, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = false, excludePositiveInboundFees = false) + + 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, Reputation.Score.max) + + 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, 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, 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, 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, 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 selects path without positive inbound fees when BLIP-18 enabled") { + // Two paths from a to e: + // Path 1: a -> b -> c -> d -> e (with positive inbound fees, more expensive with BLIP-18) + // Path 2: a -> f -> e (no inbound fees, cheaper with BLIP-18) + val g = GraphWithBalanceEstimates(DirectedGraph(Seq( + // Path 1: a -> b -> c -> d -> e (positive inbound fees on back-edges) + makeEdge(40L, a, b, minHtlc = 2 msat), + makeEdge(40L, b, a, minHtlc = 2 msat, inboundFeeBase_opt = Some(3000 msat), inboundFeeProportionalMillionth_opt = Some(30_000)), + makeEdge(41L, b, c, 1000 msat, 20_000, minHtlc = 2 msat), + makeEdge(41L, c, b, minHtlc = 2 msat, inboundFeeBase_opt = Some(3000 msat), inboundFeeProportionalMillionth_opt = Some(30_000)), + makeEdge(42L, c, d, 1000 msat, 20_000, minHtlc = 2 msat), + makeEdge(42L, d, c, minHtlc = 2 msat, inboundFeeBase_opt = Some(3000 msat), inboundFeeProportionalMillionth_opt = Some(30_000)), + makeEdge(43L, d, e, 2000 msat, 20_000, minHtlc = 2 msat), + makeEdge(43L, e, d, minHtlc = 2 msat), + // Path 2: a -> f -> e (no inbound fees) + makeEdge(50L, a, f, minHtlc = 2 msat), + makeEdge(50L, f, a, minHtlc = 2 msat), + makeEdge(51L, f, e, 8000 msat, 80_000, minHtlc = 2 msat), + makeEdge(51L, e, f, minHtlc = 2 msat), + )), 1 day) + + val Success(route :: Nil) = findRoute(g, a, e, 100_000 msat, 100_000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true, excludePositiveInboundFees = false) + + // With BLIP-18, path 2 is cheaper because path 1's positive inbound fees make it more expensive + assert(route.hops.length == 2) + assert(route.channelFee(false) == 16_000.msat) + + val recipient = ClearRecipient(e, Features.empty, 100_000 msat, CltvExpiry(400018), paymentSecret) + val Right(payment) = buildOutgoingPayment(TestConstants.emptyOrigin, paymentHash, route, recipient, Reputation.Score.max) + + assert(payment.outgoingChannel == ShortChannelId(50L)) + assert(payment.cmd.amount == 116_000.msat) + + val packet_f = payment.cmd.onion + + val add_f = UpdateAddHtlc(randomBytes32(), 0, 100_000 msat, paymentHash, CltvExpiry(400018), packet_f, None, 1, None) + val Right(relay_f@ChannelRelayPacket(_, payload_f, packet_e, _)) = decrypt(add_f, priv_f, Features.empty) + assert(payload_f.outgoing.contains(ShortChannelId(51L))) + assert(relay_f.amountToForward == 100_000.msat) + assert(relay_f.relayFeeMsat == 0.msat) + + val add_e = UpdateAddHtlc(randomBytes32(), 1, 100_000 msat, paymentHash, CltvExpiry(400018), packet_e, None, 1, 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 selects path with positive inbound fees when BLIP-18 disabled") { + // Same graph as above. Without BLIP-18, positive inbound fees are ignored and path 1 wins on lower outbound fees. + val g = GraphWithBalanceEstimates(DirectedGraph(Seq( + // Path 1: a -> b -> c -> d -> e (positive inbound fees ignored, outbound fee = 10_221) + makeEdge(40L, a, b, minHtlc = 2 msat), + makeEdge(40L, b, a, minHtlc = 2 msat, inboundFeeBase_opt = Some(1000 msat), inboundFeeProportionalMillionth_opt = Some(10_000)), + makeEdge(41L, b, c, 1000 msat, 20_000, minHtlc = 2 msat), + makeEdge(41L, c, b, minHtlc = 2 msat, inboundFeeBase_opt = Some(2000 msat), inboundFeeProportionalMillionth_opt = Some(20_000)), + makeEdge(42L, c, d, 1000 msat, 20_000, minHtlc = 2 msat), + makeEdge(42L, d, c, minHtlc = 2 msat, inboundFeeBase_opt = Some(3000 msat), inboundFeeProportionalMillionth_opt = Some(30_000)), + makeEdge(43L, d, e, 2000 msat, 20_000, minHtlc = 2 msat), + makeEdge(43L, e, d, minHtlc = 2 msat), + // Path 2: a -> f -> e (no inbound fees, outbound fee = 16_000) + makeEdge(50L, a, f, minHtlc = 2 msat), + makeEdge(50L, f, a, minHtlc = 2 msat), + makeEdge(51L, f, e, 8000 msat, 80_000, minHtlc = 2 msat), + makeEdge(51L, e, f, minHtlc = 2 msat), + )), 1 day) + + val Success(route :: Nil) = findRoute(g, a, e, 100_000 msat, 100_000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = false, excludePositiveInboundFees = false) + + // Without BLIP-18, path 1 is cheaper based on outbound fees alone + assert(route.hops.length == 4) + assert(route.channelFee(false) == 10_221.msat) + + val recipient = ClearRecipient(e, Features.empty, 100_000 msat, CltvExpiry(400018), paymentSecret) + val Right(payment) = buildOutgoingPayment(TestConstants.emptyOrigin, paymentHash, route, recipient, Reputation.Score.max) + + assert(payment.outgoingChannel == ShortChannelId(40L)) + assert(payment.cmd.amount == 110_221.msat) + + val packet_b = payment.cmd.onion + + val add_b = UpdateAddHtlc(randomBytes32(), 0, 100_000 msat, paymentHash, CltvExpiry(400018), packet_b, None, 1, None) + val Right(relay_b@ChannelRelayPacket(_, payload_b, packet_c, _)) = decrypt(add_b, priv_b, Features.empty) + assert(payload_b.outgoing.contains(ShortChannelId(41L))) + assert(relay_b.amountToForward == 107_080.msat) + assert(relay_b.relayFeeMsat == -7_080.msat) + + val add_c = UpdateAddHtlc(randomBytes32(), 1, 100_000 msat, paymentHash, CltvExpiry(400018), packet_c, None, 1, None) + val Right(relay_c@ChannelRelayPacket(_, payload_c, packet_d, _)) = decrypt(add_c, priv_c, Features.empty) + assert(payload_c.outgoing.contains(ShortChannelId(42L))) + assert(relay_c.amountToForward == 104_000.msat) + assert(relay_c.relayFeeMsat == -4_000.msat) + + val add_d = UpdateAddHtlc(randomBytes32(), 2, 100_000 msat, paymentHash, CltvExpiry(400018), packet_d, None, 1, None) + val Right(relay_d@ChannelRelayPacket(_, payload_d, packet_e, _)) = decrypt(add_d, priv_d, Features.empty) + assert(payload_d.outgoing.contains(ShortChannelId(43L))) + assert(relay_d.amountToForward == 100_000.msat) + assert(relay_d.relayFeeMsat == 0.msat) + + val add_e = UpdateAddHtlc(randomBytes32(), 3, 100_000 msat, paymentHash, CltvExpiry(400018), packet_e, None, 1, 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("prefer path with 1 msat cheaper negative inbound fee") { + // Two structurally identical 2-hop paths from a to c, both with negative inbound fees. + // Path 1 via b: relay fee = 5000 msat, inbound discount at b = -1000 msat → net cost = 4000 msat + // Path 2 via d: relay fee = 5000 msat, inbound discount at d = -1001 msat → net cost = 3999 msat + // Path 2 is 1 msat cheaper due to a more negative inbound fee and must be selected. + val g = GraphWithBalanceEstimates(DirectedGraph(Seq( + // Path 1: a -> b -> c 105_000 + makeEdge(10L, a, b, minHtlc = 2 msat), + makeEdge(10L, b, a, minHtlc = 2 msat, + inboundFeeBase_opt = None, inboundFeeProportionalMillionth_opt = None), + makeEdge(11L, b, c, feeBase = 5000 msat, minHtlc = 2 msat), + makeEdge(11L, c, b, minHtlc = 2 msat), + // Path 2: a -> d -> c 104_999 + makeEdge(20L, a, d, minHtlc = 2 msat), + makeEdge(20L, d, a, minHtlc = 2 msat, + inboundFeeBase_opt = Some(-1 msat), inboundFeeProportionalMillionth_opt = Some(0)), + makeEdge(21L, d, c, feeBase = 5000 msat, minHtlc = 2 msat), + makeEdge(21L, c, d, minHtlc = 2 msat), + )), 1 day) + + val Success(route :: Nil) = findRoute(g, a, c, 100_000 msat, 100_000 msat, numRoutes = 1, + routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), + blip18InboundFees = true, excludePositiveInboundFees = false) + + // Path 2 (via d, channels 20 and 21) wins by 1 msat + assert(route.hops.length == 2) + assert(route.channelFee(false) == 4999.msat) + + val recipient = ClearRecipient(c, Features.empty, 100_000 msat, CltvExpiry(400018), paymentSecret) + val Right(payment) = buildOutgoingPayment(TestConstants.emptyOrigin, paymentHash, route, recipient, Reputation.Score.max) + + assert(payment.outgoingChannel == ShortChannelId(20L)) + assert(payment.cmd.amount == 104_999.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 packet_d = payment.cmd.onion - { + val add_d = UpdateAddHtlc(randomBytes32(), 0, 100_000 msat, paymentHash, CltvExpiry(400018), packet_d, None, 1, None) + val Right(relay_d@ChannelRelayPacket(_, payload_d, packet_c, _)) = decrypt(add_d, priv_d, Features.empty) + assert(payload_d.outgoing.contains(ShortChannelId(21L))) + assert(relay_d.amountToForward == 100_000.msat) + assert(relay_d.relayFeeMsat == 0.msat) + + val add_c = UpdateAddHtlc(randomBytes32(), 1, 100_000 msat, paymentHash, CltvExpiry(400018), packet_c, None, 1, None) + val Right(FinalPacket(_, payload_c, _)) = decrypt(add_c, priv_c, Features.empty) + assert(payload_c.isInstanceOf[FinalPayload.Standard]) + assert(payload_c.amount == 100_000.msat) + assert(payload_c.totalAmount == 100_000.msat) + } + + test("prefer path with 1 msat cheaper outbound fee") { + // Two structurally identical 2-hop paths from a to c, both with negative inbound fees. + // Path 1 via b: relay fee = 5000 msat, inbound discount at b = -1000 msat → net cost = 4000 msat + // Path 2 via d: relay fee = 4999 msat, inbound discount at d = -1000 msat → net cost = 3999 msat + // Path 2 is 1 msat cheaper and must be selected. + val g = GraphWithBalanceEstimates(DirectedGraph(Seq( + // Path 1: a -> b -> c + makeEdge(10L, a, b, minHtlc = 2 msat), + makeEdge(10L, b, a, minHtlc = 2 msat, + inboundFeeBase_opt = Some(-1000 msat), inboundFeeProportionalMillionth_opt = Some(0)), + makeEdge(11L, b, c, feeBase = 5000 msat, minHtlc = 2 msat), + makeEdge(11L, c, b, minHtlc = 2 msat), + // Path 2: a -> d -> c + makeEdge(20L, a, d, minHtlc = 2 msat), + makeEdge(20L, d, a, minHtlc = 2 msat, + inboundFeeBase_opt = Some(-1000 msat), inboundFeeProportionalMillionth_opt = Some(0)), + makeEdge(21L, d, c, feeBase = 4999 msat, minHtlc = 2 msat), + makeEdge(21L, c, d, minHtlc = 2 msat), + )), 1 day) + + val Success(route :: Nil) = findRoute(g, a, c, 100_000 msat, 100_000 msat, numRoutes = 1, + routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), + blip18InboundFees = true, excludePositiveInboundFees = false) + + // Path 2 (via d, channels 20 and 21) wins by 1 msat + assert(route.hops.length == 2) + assert(route.channelFee(false) == 3999.msat) + + val recipient = ClearRecipient(c, Features.empty, 100_000 msat, CltvExpiry(400018), paymentSecret) + val Right(payment) = buildOutgoingPayment(TestConstants.emptyOrigin, paymentHash, route, recipient, Reputation.Score.max) + + assert(payment.outgoingChannel == ShortChannelId(20L)) + assert(payment.cmd.amount == 103_999.msat) + + val packet_d = payment.cmd.onion + + val add_d = UpdateAddHtlc(randomBytes32(), 0, 100_000 msat, paymentHash, CltvExpiry(400018), packet_d, None, 1, None) + val Right(relay_d@ChannelRelayPacket(_, payload_d, packet_c, _)) = decrypt(add_d, priv_d, Features.empty) + assert(payload_d.outgoing.contains(ShortChannelId(21L))) + assert(relay_d.amountToForward == 100_000.msat) + assert(relay_d.relayFeeMsat == 0.msat) + + val add_c = UpdateAddHtlc(randomBytes32(), 1, 100_000 msat, paymentHash, CltvExpiry(400018), packet_c, None, 1, None) + val Right(FinalPacket(_, payload_c, _)) = decrypt(add_c, priv_c, Features.empty) + assert(payload_c.isInstanceOf[FinalPayload.Standard]) + assert(payload_c.amount == 100_000.msat) + assert(payload_c.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 g = GraphWithBalanceEstimates(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)) + )), 1 day) + + val res = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true, excludePositiveInboundFees = true) + assert(res == Failure(RouteNotFound)) + } + { + val g = GraphWithBalanceEstimates(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)) + )), 1 day) + + val res = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true, excludePositiveInboundFees = true) + assert(res == Failure(RouteNotFound)) + } + { + val g = GraphWithBalanceEstimates(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)) + )), 1 day) + + val res = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true, excludePositiveInboundFees = true) + assert(res == Failure(RouteNotFound)) + } + { + val g = GraphWithBalanceEstimates(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)) + )), 1 day) + + val res = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true, excludePositiveInboundFees = true) + assert(res == Failure(RouteNotFound)) + } + { + val g = GraphWithBalanceEstimates(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)) + )), 1 day) + + val res = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true, excludePositiveInboundFees = true) + assert(res == Failure(RouteNotFound)) + } + } + + // run tests from RouteCalculationSpec with inbound fees enabled + + test("calculate simple route") { val g = GraphWithBalanceEstimates(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(2L, b, c, 1 msat, 10, cltvDelta = CltvExpiryDelta(1)), makeEdge(3L, c, d, 1 msat, 10, cltvDelta = CltvExpiryDelta(1)), makeEdge(4L, d, e, 1 msat, 10, cltvDelta = CltvExpiryDelta(1)) )), 1 day) - val res = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true, excludePositiveInboundFees = true) - assert(res == Failure(RouteNotFound)) + val Success(route :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(route2Ids(route) == 1 :: 2 :: 3 :: 4 :: Nil) } - { + + test("check fee against max pct properly") { + // fee is acceptable if it is either: + // - below our maximum fee base + // - below our maximum fraction of the paid amount + // here we have a maximum fee base of 1 msat, and all our updates have a base fee of 10 msat + // so our fee will always be above the base fee, and we will always check that it is below our maximum percentage + // of the amount being paid + val routeParams = DEFAULT_ROUTE_PARAMS.modify(_.boundaries.maxFeeFlat).setTo(1 msat) + val maxFee = routeParams.getMaxFee(DEFAULT_AMOUNT_MSAT) + val g = GraphWithBalanceEstimates(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)) + makeEdge(1L, a, b, 10 msat, 10, cltvDelta = CltvExpiryDelta(1)), + makeEdge(2L, b, c, 10 msat, 10, cltvDelta = CltvExpiryDelta(1)), + makeEdge(3L, c, d, 10 msat, 10, cltvDelta = CltvExpiryDelta(1)), + makeEdge(4L, d, e, 10 msat, 10, cltvDelta = CltvExpiryDelta(1)) + )), 1 day) + + val Success(route :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, maxFee, numRoutes = 1, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(route2Ids(route) == 1 :: 2 :: 3 :: 4 :: Nil) + } + + test("calculate the shortest path (correct fees)") { + val (a, b, c, d, e, f) = ( + PublicKey(hex"02999fa724ec3c244e4da52b4a91ad421dc96c9a810587849cd4b2469313519c73"), // a: source + PublicKey(hex"03f1cb1af20fe9ccda3ea128e27d7c39ee27375c8480f11a87c17197e97541ca6a"), + PublicKey(hex"0358e32d245ff5f5a3eb14c78c6f69c67cea7846bdf9aeeb7199e8f6fbb0306484"), + PublicKey(hex"029e059b6780f155f38e83601969919aae631ddf6faed58fe860c72225eb327d7c"), // d: target + PublicKey(hex"03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f"), + PublicKey(hex"020c65be6f9252e85ae2fe9a46eed892cb89565e2157730e78311b1621a0db4b22") + ) + + // note: we don't actually use floating point numbers + // cost(CD) = 10005 = amountMsat + 1 + (amountMsat * 400 / 1000000) + // cost(BC) = 10009,0015 = (cost(CD) + 1 + (cost(CD) * 300 / 1000000) + // cost(FD) = 10002 = amountMsat + 1 + (amountMsat * 100 / 1000000) + // cost(EF) = 10007,0008 = cost(FD) + 1 + (cost(FD) * 400 / 1000000) + // cost(AE) = 10007 -> A is source, shortest path found + // cost(AB) = 10009 + // + // The amounts that need to be sent through each edge are then: + // + // +--- A ---+ + // 10009,0015 msat | | 10007,0008 msat + // B E + // 10005 msat | | 10002 msat + // C F + // 10000 msat | | 10000 msat + // +--> D <--+ + + val amount = 10000 msat + val expectedCost = 10007 msat + val graph = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, feeBase = 1 msat, feeProportionalMillionth = 200, minHtlc = 0 msat), + makeEdge(4L, a, e, feeBase = 1 msat, feeProportionalMillionth = 200, minHtlc = 0 msat), + makeEdge(2L, b, c, feeBase = 1 msat, feeProportionalMillionth = 300, minHtlc = 0 msat), + makeEdge(3L, c, d, feeBase = 1 msat, feeProportionalMillionth = 400, minHtlc = 0 msat), + makeEdge(5L, e, f, feeBase = 1 msat, feeProportionalMillionth = 400, minHtlc = 0 msat), + makeEdge(6L, f, d, feeBase = 1 msat, feeProportionalMillionth = 100, minHtlc = 0 msat) )), 1 day) - val res = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true, excludePositiveInboundFees = true) - assert(res == Failure(RouteNotFound)) + val Success(route :: Nil) = findRoute(graph, a, d, amount, maxFee = 7 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val weightedPath = Graph.pathWeight(graph.balances, a, route2Edges(route), amount, BlockHeight(0), NO_WEIGHT_RATIOS, includeLocalChannelCost = false, graph.graph, enableInboundFees = false) + assert(route2Ids(route) == 4 :: 5 :: 6 :: Nil) + assert(weightedPath.length == 3) + assert(weightedPath.amount == expectedCost) + + // update channel 5 so that it can route the final amount (10000) but not the amount + fees (10002) + val graph1 = graph.addEdge(makeEdge(5L, e, f, feeBase = 1 msat, feeProportionalMillionth = 400, minHtlc = 0 msat, maxHtlc = Some(10001 msat))) + val graph2 = graph.addEdge(makeEdge(5L, e, f, feeBase = 1 msat, feeProportionalMillionth = 400, minHtlc = 0 msat, capacity = 10 sat)) + val graph3 = graph.addEdge(makeEdge(5L, e, f, feeBase = 1 msat, feeProportionalMillionth = 400, minHtlc = 0 msat, balance_opt = Some(10001 msat))) + for (g <- Seq(graph1, graph2, graph3)) { + val Success(route1 :: Nil) = findRoute(g, a, d, amount, maxFee = 10 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(route2Ids(route1) == 1 :: 2 :: 3 :: Nil) + } } - { + + test("calculate route considering the direct channel pays no fees") { val g = GraphWithBalanceEstimates(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)) + makeEdge(1L, a, b, 5 msat, 0), // a -> b + makeEdge(2L, a, d, 15 msat, 0), // a -> d this goes a bit closer to the target and asks for higher fees but is a direct channel + makeEdge(3L, b, c, 5 msat, 0), // b -> c + makeEdge(4L, c, d, 5 msat, 0), // c -> d + makeEdge(5L, d, e, 5 msat, 0) // d -> e )), 1 day) - val res = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true, excludePositiveInboundFees = true) - assert(res == Failure(RouteNotFound)) + val Success(route :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(route2Ids(route) == 2 :: 5 :: Nil) } - { + + test("calculate simple route (add and remove edges") { val g = GraphWithBalanceEstimates(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)) + makeEdge(1L, a, b, 0 msat, 0), + makeEdge(2L, b, c, 0 msat, 0), + makeEdge(3L, c, d, 0 msat, 0), + makeEdge(4L, d, e, 0 msat, 0) + )), 1 day) + + val Success(route1 :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(route2Ids(route1) == 1 :: 2 :: 3 :: 4 :: Nil) + + val graphWithRemovedEdge = g.disableEdge(ChannelDesc(ShortChannelId(3L), c, d)) + val route2 = findRoute(graphWithRemovedEdge, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(route2 == Failure(RouteNotFound)) + } + + test("calculate the shortest path (hardcoded nodes)") { + val (f, g, h, i) = ( + PublicKey(hex"02999fa724ec3c244e4da52b4a91ad421dc96c9a810587849cd4b2469313519c73"), // source + PublicKey(hex"03f1cb1af20fe9ccda3ea128e27d7c39ee27375c8480f11a87c17197e97541ca6a"), + PublicKey(hex"0358e32d245ff5f5a3eb14c78c6f69c67cea7846bdf9aeeb7199e8f6fbb0306484"), + PublicKey(hex"029e059b6780f155f38e83601969919aae631ddf6faed58fe860c72225eb327d7c") // target + ) + + val graph = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, f, g, 1 msat, 0), + makeEdge(2L, g, h, 1 msat, 0), + makeEdge(3L, h, i, 1 msat, 0), + makeEdge(4L, f, h, 50 msat, 0) // more expensive but fee will be ignored since f is the payer + )), 1 day) + + val Success(route :: Nil) = findRoute(graph, f, i, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(route2Ids(route) == 4 :: 3 :: Nil) + } + + test("calculate the shortest path (select direct channel)") { + val (f, g, h, i) = ( + PublicKey(hex"02999fa724ec3c244e4da52b4a91ad421dc96c9a810587849cd4b2469313519c73"), // source + PublicKey(hex"03f1cb1af20fe9ccda3ea128e27d7c39ee27375c8480f11a87c17197e97541ca6a"), + PublicKey(hex"0358e32d245ff5f5a3eb14c78c6f69c67cea7846bdf9aeeb7199e8f6fbb0306484"), + PublicKey(hex"029e059b6780f155f38e83601969919aae631ddf6faed58fe860c72225eb327d7c") // target + ) + + val graph = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, f, g, 0 msat, 0), + makeEdge(4L, f, i, 50 msat, 0), // our starting node F has a direct channel with I + makeEdge(2L, g, h, 0 msat, 0), + makeEdge(3L, h, i, 0 msat, 0) + )), 1 day) + + val Success(route1 :: route2 :: Nil) = findRoute(graph, f, i, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 2, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(route2Ids(route1) == 4 :: Nil) + assert(route2Ids(route2) == 1 :: 2 :: 3 :: Nil) + } + + test("find a route using channels with htlMaximumMsat close to the payment amount") { + val (f, g, h, i) = ( + PublicKey(hex"02999fa724ec3c244e4da52b4a91ad421dc96c9a810587849cd4b2469313519c73"), // F source + PublicKey(hex"03f1cb1af20fe9ccda3ea128e27d7c39ee27375c8480f11a87c17197e97541ca6a"), // G + PublicKey(hex"0358e32d245ff5f5a3eb14c78c6f69c67cea7846bdf9aeeb7199e8f6fbb0306484"), // H + PublicKey(hex"029e059b6780f155f38e83601969919aae631ddf6faed58fe860c72225eb327d7c") // I target + ) + + val graph = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, f, g, 1 msat, 0, balance_opt = Some(DEFAULT_AMOUNT_MSAT + 50.msat)), + // the maximum htlc allowed by this channel is only 50 msat greater than what we're sending + makeEdge(2L, g, h, 1 msat, 0, maxHtlc = Some(DEFAULT_AMOUNT_MSAT + 50.msat)), + makeEdge(3L, h, i, 1 msat, 0) + )), 1 day) + + val Success(route :: Nil) = findRoute(graph, f, i, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(route2Ids(route) == 1 :: 2 :: 3 :: Nil) + } + + test("find a route using channels with htlMinimumMsat close to the payment amount") { + val (f, g, h, i) = ( + PublicKey(hex"02999fa724ec3c244e4da52b4a91ad421dc96c9a810587849cd4b2469313519c73"), // F source + PublicKey(hex"03f1cb1af20fe9ccda3ea128e27d7c39ee27375c8480f11a87c17197e97541ca6a"), // G + PublicKey(hex"0358e32d245ff5f5a3eb14c78c6f69c67cea7846bdf9aeeb7199e8f6fbb0306484"), // H + PublicKey(hex"029e059b6780f155f38e83601969919aae631ddf6faed58fe860c72225eb327d7c") // I target + ) + + val graph = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, f, g, 1 msat, 0, balance_opt = Some(DEFAULT_AMOUNT_MSAT + 50.msat)), + // this channel requires a minimum amount that is larger than what we are sending + makeEdge(2L, g, h, 1 msat, 0, minHtlc = DEFAULT_AMOUNT_MSAT + 50.msat), + makeEdge(3L, h, i, 1 msat, 0) + )), 1 day) + + val route = findRoute(graph, f, i, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(route == Failure(RouteNotFound)) + } + + test("if there are multiple channels between the same node, select the cheapest") { + val (f, g, h, i) = ( + PublicKey(hex"02999fa724ec3c244e4da52b4a91ad421dc96c9a810587849cd4b2469313519c73"), // F source + PublicKey(hex"03f1cb1af20fe9ccda3ea128e27d7c39ee27375c8480f11a87c17197e97541ca6a"), // G + PublicKey(hex"0358e32d245ff5f5a3eb14c78c6f69c67cea7846bdf9aeeb7199e8f6fbb0306484"), // H + PublicKey(hex"029e059b6780f155f38e83601969919aae631ddf6faed58fe860c72225eb327d7c") // I target + ) + + val graph = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, f, g, 0 msat, 0), + makeEdge(2L, g, h, 5 msat, 5), // expensive g -> h channel + makeEdge(6L, g, h, 0 msat, 0), // cheap g -> h channel + makeEdge(3L, h, i, 0 msat, 0) + )), 1 day) + + val Success(route :: Nil) = findRoute(graph, f, i, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(route2Ids(route) == 1 :: 6 :: 3 :: Nil) + } + + test("if there are multiple channels between the same node, select one that has enough balance") { + val (f, g, h, i) = ( + PublicKey(hex"02999fa724ec3c244e4da52b4a91ad421dc96c9a810587849cd4b2469313519c73"), // F source + PublicKey(hex"03f1cb1af20fe9ccda3ea128e27d7c39ee27375c8480f11a87c17197e97541ca6a"), // G + PublicKey(hex"0358e32d245ff5f5a3eb14c78c6f69c67cea7846bdf9aeeb7199e8f6fbb0306484"), // H + PublicKey(hex"029e059b6780f155f38e83601969919aae631ddf6faed58fe860c72225eb327d7c") // I target + ) + + val graph = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, f, g, 0 msat, 0), + makeEdge(2L, g, h, 5 msat, 5, balance_opt = Some(DEFAULT_AMOUNT_MSAT + 1.msat)), // expensive g -> h channel with enough balance + makeEdge(6L, g, h, 0 msat, 0, balance_opt = Some(DEFAULT_AMOUNT_MSAT - 10.msat)), // cheap g -> h channel without enough balance + makeEdge(3L, h, i, 0 msat, 0) )), 1 day) - val res = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true, excludePositiveInboundFees = true) - assert(res == Failure(RouteNotFound)) + val Success(route :: Nil) = findRoute(graph, f, i, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(route2Ids(route) == 1 :: 2 :: 3 :: Nil) } - { + + test("calculate longer but cheaper route") { val g = GraphWithBalanceEstimates(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)) + makeEdge(1L, a, b, 0 msat, 0), + makeEdge(2L, b, c, 0 msat, 0), + makeEdge(3L, c, d, 0 msat, 0), + makeEdge(4L, d, e, 0 msat, 0), + makeEdge(5L, b, e, 10 msat, 10) )), 1 day) - val res = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true, excludePositiveInboundFees = true) - assert(res == Failure(RouteNotFound)) + val Success(route :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(route2Ids(route) == 1 :: 2 :: 3 :: 4 :: Nil) } - } -} + test("no local channels") { + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(2L, b, c, 0 msat, 0), + makeEdge(4L, d, e, 0 msat, 0) + )), 1 day) -object Blip18RouteCalculationSpec { + val route = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(route == Failure(RouteNotFound)) + } - 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 + test("route not found") { + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, 0 msat, 0), + makeEdge(2L, b, c, 0 msat, 0), + makeEdge(4L, d, e, 0 msat, 0) + )), 1 day) - val NO_WEIGHT_RATIOS: HeuristicsConstants = HeuristicsConstants(0, RelayFees(0 msat, 0), RelayFees(0 msat, 0), useLogProbability = false, usePastRelaysData = false) - val DEFAULT_ROUTE_PARAMS = PathFindingConf( - randomize = false, - boundaries = SearchBoundaries(21000 msat, 0.03, 6, CltvExpiryDelta(2016)), - NO_WEIGHT_RATIOS, - MultiPartParams(1000 msat, 10, FullCapacity), - experimentName = "my-test-experiment", - experimentPercentage = 100).getDefaultRouteParams + val route = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(route == Failure(RouteNotFound)) + } - val DUMMY_SIG = Transactions.PlaceHolderSig + test("route not found (source OR target node not connected)") { + val priv_a = randomKey() + val a = priv_a.publicKey + val annA = makeNodeAnnouncement(priv_a, "A", Color(0, 0, 0), Nil, Features.empty) + val priv_e = randomKey() + val e = priv_e.publicKey + val annE = makeNodeAnnouncement(priv_e, "E", Color(0, 0, 0), Nil, Features.empty) - 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) - } + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(2L, b, c, 0 msat, 0), + makeEdge(4L, c, d, 0 msat, 0) + )).addOrUpdateVertex(annA).addOrUpdateVertex(annE), 1 day) - 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) - } + assert(findRoute(g, a, d, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) == Failure(RouteNotFound)) + assert(findRoute(g, b, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) == Failure(RouteNotFound)) + } - 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 + test("route not found (amount too high OR too low)") { + val highAmount = DEFAULT_AMOUNT_MSAT * 10 + val lowAmount = DEFAULT_AMOUNT_MSAT / 10 + + val edgesHi = List( + makeEdge(1L, a, b, 0 msat, 0), + makeEdge(2L, b, c, 0 msat, 0, maxHtlc = Some(DEFAULT_AMOUNT_MSAT)), + makeEdge(3L, c, d, 0 msat, 0) + ) + + val edgesLo = List( + makeEdge(1L, a, b, 0 msat, 0), + makeEdge(2L, b, c, 0 msat, 0, minHtlc = DEFAULT_AMOUNT_MSAT), + makeEdge(3L, c, d, 0 msat, 0) + ) + + val g = GraphWithBalanceEstimates(DirectedGraph(edgesHi), 1 day) + val g1 = GraphWithBalanceEstimates(DirectedGraph(edgesLo), 1 day) + + assert(findRoute(g, a, d, highAmount, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) == Failure(RouteNotFound)) + assert(findRoute(g1, a, d, lowAmount, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) == Failure(RouteNotFound)) } - 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 - ) + + test("route not found (balance too low)") { + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, 1 msat, 2, minHtlc = 10000 msat), + makeEdge(2L, b, c, 1 msat, 2, minHtlc = 10000 msat), + makeEdge(3L, c, d, 1 msat, 2, minHtlc = 10000 msat) + )), 1 day) + assert(findRoute(g, a, d, 15000 msat, 100 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true).isSuccess) + + // not enough balance on the last edge + val g1 = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, 1 msat, 2, minHtlc = 10000 msat), + makeEdge(2L, b, c, 1 msat, 2, minHtlc = 10000 msat), + makeEdge(3L, c, d, 1 msat, 2, minHtlc = 10000 msat, balance_opt = Some(10000 msat)) + )), 1 day) + // not enough balance on intermediate edge (taking fee into account) + val g2 = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, 1 msat, 2, minHtlc = 10000 msat), + makeEdge(2L, b, c, 1 msat, 2, minHtlc = 10000 msat, balance_opt = Some(15000 msat)), + makeEdge(3L, c, d, 1 msat, 2, minHtlc = 10000 msat) + )), 1 day) + // no enough balance on first edge (taking fee into account) + val g3 = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, 1 msat, 2, minHtlc = 10000 msat, balance_opt = Some(15000 msat)), + makeEdge(2L, b, c, 1 msat, 2, minHtlc = 10000 msat), + makeEdge(3L, c, d, 1 msat, 2, minHtlc = 10000 msat) + )), 1 day) + Seq(g1, g2, g3).foreach(g => assert(findRoute(g, a, d, 15000 msat, 100 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) == Failure(RouteNotFound))) + } + + test("route to self") { + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, 0 msat, 0), + makeEdge(2L, b, c, 0 msat, 0), + makeEdge(3L, c, d, 0 msat, 0) + )), 1 day) + + val route = findRoute(g, a, a, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(route == Failure(CannotRouteToSelf)) + } + + test("route to immediate neighbor") { + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, 0 msat, 0, balance_opt = Some(DEFAULT_AMOUNT_MSAT)), + makeEdge(2L, b, c, 0 msat, 0), + makeEdge(3L, c, d, 0 msat, 0), + makeEdge(4L, d, e, 0 msat, 0) + )), 1 day) + + val Success(route :: Nil) = findRoute(g, a, b, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(route2Ids(route) == 1 :: Nil) + } + + test("directed graph") { + // a->e works, e->a fails + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, 0 msat, 0), + makeEdge(2L, b, c, 0 msat, 0), + makeEdge(3L, c, d, 0 msat, 0), + makeEdge(4L, d, e, 0 msat, 0) + )), 1 day) + + val Success(route1 :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(route2Ids(route1) == 1 :: 2 :: 3 :: 4 :: Nil) + + val route2 = findRoute(g, e, a, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(route2 == Failure(RouteNotFound)) + } + + test("calculate route and return metadata") { + val DUMMY_SIG = Transactions.PlaceHolderSig + + val uab = ChannelUpdate(DUMMY_SIG, Block.RegtestGenesisBlock.hash, ShortChannelId(1L), 0 unixsec, ChannelUpdate.MessageFlags(dontForward = false), ChannelUpdate.ChannelFlags.DUMMY, CltvExpiryDelta(1), 42 msat, 2500 msat, 140, DEFAULT_CAPACITY.toMilliSatoshi) + val uba = ChannelUpdate(DUMMY_SIG, Block.RegtestGenesisBlock.hash, ShortChannelId(1L), 1 unixsec, ChannelUpdate.MessageFlags(dontForward = false), ChannelUpdate.ChannelFlags(isNode1 = false, isEnabled = false), CltvExpiryDelta(1), 43 msat, 2501 msat, 141, DEFAULT_CAPACITY.toMilliSatoshi) + val ubc = ChannelUpdate(DUMMY_SIG, Block.RegtestGenesisBlock.hash, ShortChannelId(2L), 1 unixsec, ChannelUpdate.MessageFlags(dontForward = false), ChannelUpdate.ChannelFlags.DUMMY, CltvExpiryDelta(1), 44 msat, 2502 msat, 142, DEFAULT_CAPACITY.toMilliSatoshi) + val ucb = ChannelUpdate(DUMMY_SIG, Block.RegtestGenesisBlock.hash, ShortChannelId(2L), 1 unixsec, ChannelUpdate.MessageFlags(dontForward = false), ChannelUpdate.ChannelFlags(isNode1 = false, isEnabled = false), CltvExpiryDelta(1), 45 msat, 2503 msat, 143, DEFAULT_CAPACITY.toMilliSatoshi) + val ucd = ChannelUpdate(DUMMY_SIG, Block.RegtestGenesisBlock.hash, ShortChannelId(3L), 1 unixsec, ChannelUpdate.MessageFlags(dontForward = false), ChannelUpdate.ChannelFlags.DUMMY, CltvExpiryDelta(1), 46 msat, 2504 msat, 144, 500_000_000 msat) + val udc = ChannelUpdate(DUMMY_SIG, Block.RegtestGenesisBlock.hash, ShortChannelId(3L), 1 unixsec, ChannelUpdate.MessageFlags(dontForward = false), ChannelUpdate.ChannelFlags(isNode1 = false, isEnabled = false), CltvExpiryDelta(1), 47 msat, 2505 msat, 145, DEFAULT_CAPACITY.toMilliSatoshi) + val ude = ChannelUpdate(DUMMY_SIG, Block.RegtestGenesisBlock.hash, ShortChannelId(4L), 1 unixsec, ChannelUpdate.MessageFlags(dontForward = false), ChannelUpdate.ChannelFlags.DUMMY, CltvExpiryDelta(1), 48 msat, 2506 msat, 146, DEFAULT_CAPACITY.toMilliSatoshi) + val ued = ChannelUpdate(DUMMY_SIG, Block.RegtestGenesisBlock.hash, ShortChannelId(4L), 1 unixsec, ChannelUpdate.MessageFlags(dontForward = false), ChannelUpdate.ChannelFlags(isNode1 = false, isEnabled = false), CltvExpiryDelta(1), 49 msat, 2507 msat, 147, DEFAULT_CAPACITY.toMilliSatoshi) + + val edges = Seq( + GraphEdge(ChannelDesc(ShortChannelId(1L), a, b), HopRelayParams.FromAnnouncement(uab), DEFAULT_CAPACITY, None), + GraphEdge(ChannelDesc(ShortChannelId(1L), b, a), HopRelayParams.FromAnnouncement(uba), DEFAULT_CAPACITY, None), + GraphEdge(ChannelDesc(ShortChannelId(2L), b, c), HopRelayParams.FromAnnouncement(ubc), DEFAULT_CAPACITY, None), + GraphEdge(ChannelDesc(ShortChannelId(2L), c, b), HopRelayParams.FromAnnouncement(ucb), DEFAULT_CAPACITY, None), + GraphEdge(ChannelDesc(ShortChannelId(3L), c, d), HopRelayParams.FromAnnouncement(ucd), DEFAULT_CAPACITY, None), + GraphEdge(ChannelDesc(ShortChannelId(3L), d, c), HopRelayParams.FromAnnouncement(udc), DEFAULT_CAPACITY, None), + GraphEdge(ChannelDesc(ShortChannelId(4L), d, e), HopRelayParams.FromAnnouncement(ude), DEFAULT_CAPACITY, None), + GraphEdge(ChannelDesc(ShortChannelId(4L), e, d), HopRelayParams.FromAnnouncement(ued), DEFAULT_CAPACITY, None) + ) + + val g = GraphWithBalanceEstimates(DirectedGraph(edges), 1 day) + val Success(route :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(route.hops == channelHopFromUpdate(a, b, uab) :: channelHopFromUpdate(b, c, ubc) :: channelHopFromUpdate(c, d, ucd) :: channelHopFromUpdate(d, e, ude) :: Nil) + } + + test("blacklist routes") { + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, 0 msat, 0), + makeEdge(2L, b, c, 0 msat, 0), + makeEdge(3L, c, d, 0 msat, 0), + makeEdge(4L, d, e, 0 msat, 0) + )), 1 day) + + val route1 = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, ignoredEdges = Set(ChannelDesc(ShortChannelId(3L), c, d)), routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(route1 == Failure(RouteNotFound)) + + // verify that we left the graph untouched + assert(g.graph.containsEdge(ChannelDesc(ShortChannelId(3), c, d))) + assert(g.graph.containsVertex(c)) + assert(g.graph.containsVertex(d)) + + // make sure we can find a route if without the blacklist + val Success(route2 :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(route2Ids(route2) == 1 :: 2 :: 3 :: 4 :: Nil) + } + + test("route to a destination that is not in the graph (with assisted routes)") { + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, 10 msat, 10), + makeEdge(2L, b, c, 10 msat, 10), + makeEdge(3L, c, d, 10 msat, 10) + )), 1 day) + + val route = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(route == Failure(RouteNotFound)) + + // now we add the missing edge to reach the destination + val extraGraphEdges = Set(makeEdge(4L, d, e, 5 msat, 5)) + val Success(route1 :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, extraEdges = extraGraphEdges, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(route2Ids(route1) == 1 :: 2 :: 3 :: 4 :: Nil) + } + + test("route from a source that is not in the graph (with assisted routes)") { + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(2L, b, c, 10 msat, 10), + makeEdge(3L, c, d, 10 msat, 10) + )), 1 day) + + val route = findRoute(g, a, d, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(route == Failure(RouteNotFound)) + + // now we add the missing starting edge + val extraGraphEdges = Set(makeEdge(1L, a, b, 5 msat, 5)) + val Success(route1 :: Nil) = findRoute(g, a, d, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, extraEdges = extraGraphEdges, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(route2Ids(route1) == 1 :: 2 :: 3 :: Nil) + } + + test("verify that extra hops takes precedence over known channels") { + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, 10 msat, 10), + makeEdge(2L, b, c, 10 msat, 10), + makeEdge(3L, c, d, 10 msat, 10), + makeEdge(4L, d, e, 10 msat, 10) + )), 1 day) + + val Success(route1 :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(route2Ids(route1) == 1 :: 2 :: 3 :: 4 :: Nil) + assert(route1.hops(1).params.relayFees.feeBase == 10.msat) + + val extraGraphEdges = Set(makeEdge(2L, b, c, 5 msat, 5)) + val Success(route2 :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, extraEdges = extraGraphEdges, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(route2Ids(route2) == 1 :: 2 :: 3 :: 4 :: Nil) + assert(route2.hops(1).params.relayFees.feeBase == 5.msat) + } + + test("compute ignored channels") { + val f = randomKey().publicKey + val g = randomKey().publicKey + val h = randomKey().publicKey + val i = randomKey().publicKey + val j = randomKey().publicKey + + val channels = Map( + ShortChannelId(1L) -> makeChannel(1L, a, b), + ShortChannelId(2L) -> makeChannel(2L, b, c), + ShortChannelId(3L) -> makeChannel(3L, c, d), + ShortChannelId(4L) -> makeChannel(4L, d, e), + ShortChannelId(5L) -> makeChannel(5L, f, g), + ShortChannelId(6L) -> makeChannel(6L, f, h), + ShortChannelId(7L) -> makeChannel(7L, h, i), + ShortChannelId(8L) -> makeChannel(8L, i, j) + ) + + val edges = List( + makeEdge(1L, a, b, 10 msat, 10), + makeEdge(2L, b, c, 10 msat, 10), + makeEdge(2L, c, b, 10 msat, 10), + makeEdge(3L, c, d, 10 msat, 10), + makeEdge(4L, d, e, 10 msat, 10), + makeEdge(5L, f, g, 10 msat, 10), + makeEdge(6L, f, h, 10 msat, 10), + makeEdge(7L, h, i, 10 msat, 10), + makeEdge(8L, i, j, 10 msat, 10) + ) + + val publicChannels = channels.map { case (shortChannelId, announcement) => + 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) + } + + val ignored = getIgnoredChannelDesc(publicChannels, ignoreNodes = Set(c, j, randomKey().publicKey)) + assert(ignored.toSet.contains(ChannelDesc(ShortChannelId(2L), b, c))) + assert(ignored.toSet.contains(ChannelDesc(ShortChannelId(2L), c, b))) + assert(ignored.toSet.contains(ChannelDesc(ShortChannelId(3L), c, d))) + assert(ignored.toSet.contains(ChannelDesc(ShortChannelId(8L), i, j))) + } + + test("limit routes to 20 hops") { + val nodes = (for (_ <- 0 until 22) yield randomKey().publicKey).toList + val edges = nodes + .zip(nodes.drop(1)) // (0, 1) :: (1, 2) :: ... + .zipWithIndex // ((0, 1), 0) :: ((1, 2), 1) :: ... + .map { case ((na, nb), index) => makeEdge(index, na, nb, 5 msat, 0) } + + val g = GraphWithBalanceEstimates(DirectedGraph(edges), 1 day) + + assert(findRoute(g, nodes(0), nodes(18), DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true).map(r => route2Ids(r.head)) == Success(0 until 18)) + assert(findRoute(g, nodes(0), nodes(19), DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true).map(r => route2Ids(r.head)) == Success(0 until 19)) + assert(findRoute(g, nodes(0), nodes(20), DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true).map(r => route2Ids(r.head)) == Success(0 until 20)) + assert(findRoute(g, nodes(0), nodes(21), DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) == Failure(RouteNotFound)) + } + + test("ignore cheaper route when it has more than 20 hops") { + val nodes = (for (_ <- 0 until 50) yield randomKey().publicKey).toList + + val edges = nodes + .zip(nodes.drop(1)) // (0, 1) :: (1, 2) :: ... + .zipWithIndex // ((0, 1), 0) :: ((1, 2), 1) :: ... + .map { case ((na, nb), index) => makeEdge(index, na, nb, 1 msat, 0) } + + val expensiveShortEdge = makeEdge(99, nodes(2), nodes(48), 1000 msat, 0) // expensive shorter route + + val g = GraphWithBalanceEstimates(DirectedGraph(expensiveShortEdge :: edges), 1 day) + + val Success(route :: Nil) = findRoute(g, nodes(0), nodes(49), DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(route2Ids(route) == 0 :: 1 :: 99 :: 48 :: Nil) + } + + test("ignore cheaper route when it has more than the requested CLTV") { + val f = randomKey().publicKey + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1, a, b, feeBase = 1 msat, 0, minHtlc = 0 msat, maxHtlc = None, CltvExpiryDelta(50)), + makeEdge(2, b, c, feeBase = 1 msat, 0, minHtlc = 0 msat, maxHtlc = None, CltvExpiryDelta(50)), + makeEdge(3, c, d, feeBase = 1 msat, 0, minHtlc = 0 msat, maxHtlc = None, CltvExpiryDelta(50)), + makeEdge(4, a, e, feeBase = 1 msat, 0, minHtlc = 0 msat, maxHtlc = None, CltvExpiryDelta(9)), + makeEdge(5, e, f, feeBase = 5 msat, 0, minHtlc = 0 msat, maxHtlc = None, CltvExpiryDelta(9)), + makeEdge(6, f, d, feeBase = 5 msat, 0, minHtlc = 0 msat, maxHtlc = None, CltvExpiryDelta(9)) + )), 1 day) + + val Success(route :: Nil) = findRoute(g, a, d, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.modify(_.boundaries.maxCltv).setTo(CltvExpiryDelta(28)), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(route2Ids(route) == 4 :: 5 :: 6 :: Nil) + } + + test("ignore cheaper route when it grows longer than the requested size") { + val f = randomKey().publicKey + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1, a, b, feeBase = 1 msat, 0, minHtlc = 0 msat, maxHtlc = None, CltvExpiryDelta(9)), + makeEdge(2, b, c, feeBase = 1 msat, 0, minHtlc = 0 msat, maxHtlc = None, CltvExpiryDelta(9)), + makeEdge(3, c, d, feeBase = 1 msat, 0, minHtlc = 0 msat, maxHtlc = None, CltvExpiryDelta(9)), + makeEdge(4, d, e, feeBase = 1 msat, 0, minHtlc = 0 msat, maxHtlc = None, CltvExpiryDelta(9)), + makeEdge(5, e, f, feeBase = 5 msat, 0, minHtlc = 0 msat, maxHtlc = None, CltvExpiryDelta(9)), + makeEdge(6, b, f, feeBase = 5 msat, 0, minHtlc = 0 msat, maxHtlc = None, CltvExpiryDelta(9)) + )), 1 day) + + val Success(route :: Nil) = findRoute(g, a, f, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.modify(_.boundaries.maxRouteLength).setTo(3), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(route2Ids(route) == 1 :: 6 :: Nil) + } + + test("ignore loops") { + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, 10 msat, 10), + makeEdge(2L, b, c, 10 msat, 10), + makeEdge(3L, c, a, 10 msat, 10), + makeEdge(4L, c, d, 10 msat, 10), + makeEdge(5L, d, e, 10 msat, 10) + )), 1 day) + + val Success(route :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(route2Ids(route) == 1 :: 2 :: 4 :: 5 :: Nil) + } + + test("ensure the route calculation terminates correctly when selecting 0-fees edges") { + // the graph contains a possible 0-cost path that goes back on its steps ( e -> f, f -> e ) + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, 10 msat, 10), // a -> b + makeEdge(2L, b, c, 10 msat, 10), + makeEdge(4L, c, d, 10 msat, 10), + makeEdge(3L, b, e, 0 msat, 0), // b -> e + makeEdge(6L, e, f, 0 msat, 0), // e -> f + makeEdge(6L, f, e, 0 msat, 0), // e <- f + makeEdge(5L, e, d, 0 msat, 0) // e -> d + )), 1 day) + + val Success(route :: Nil) = findRoute(g, a, d, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(route2Ids(route) == 1 :: 3 :: 5 :: Nil) + } + + // +---+ +---+ +---+ + // | A |-----+ +--->| B |--->| C | + // +---+ | | +---+ +---+ + // ^ | +---+ | | + // | +--->| E |---+ | + // | | +---+ | | + // +---+ | | +---+ | + // | D |-----+ +--->| F |<-----+ + // +---+ +---+ + test("find the k-shortest paths in a graph, k=4") { + val (a, b, c, d, e, f) = ( + PublicKey(hex"02999fa724ec3c244e4da52b4a91ad421dc96c9a810587849cd4b2469313519c73"), + PublicKey(hex"03f1cb1af20fe9ccda3ea128e27d7c39ee27375c8480f11a87c17197e97541ca6a"), + PublicKey(hex"0358e32d245ff5f5a3eb14c78c6f69c67cea7846bdf9aeeb7199e8f6fbb0306484"), + PublicKey(hex"029e059b6780f155f38e83601969919aae631ddf6faed58fe860c72225eb327d7c"), + PublicKey(hex"02f38f4e37142cc05df44683a83e22dea608cf4691492829ff4cf99888c5ec2d3a"), + PublicKey(hex"03fc5b91ce2d857f146fd9b986363374ffe04dc143d8bcd6d7664c8873c463cdfc") + ) + + val g1 = GraphWithBalanceEstimates(DirectedGraph(Seq( + makeEdge(1L, d, a, 1 msat, 0, balance_opt = Some(DEFAULT_AMOUNT_MSAT + 4.msat)), + makeEdge(2L, d, e, 1 msat, 0, balance_opt = Some(DEFAULT_AMOUNT_MSAT + 3.msat)), + makeEdge(3L, a, e, 1 msat, 0, balance_opt = Some(DEFAULT_AMOUNT_MSAT + 3.msat)), + makeEdge(4L, e, b, 1 msat, 0, balance_opt = Some(DEFAULT_AMOUNT_MSAT + 2.msat)), + makeEdge(5L, e, f, 1 msat, 0, balance_opt = Some(DEFAULT_AMOUNT_MSAT)), + makeEdge(6L, b, c, 1 msat, 0, balance_opt = Some(DEFAULT_AMOUNT_MSAT + 1.msat)), + makeEdge(7L, c, f, 1 msat, 0, balance_opt = Some(DEFAULT_AMOUNT_MSAT)) + )), 1 day) + + val fourShortestPaths = Graph.yenKshortestPaths(g1, d, f, DEFAULT_AMOUNT_MSAT, Set.empty, Set.empty, Set.empty, pathsToFind = 4, NO_WEIGHT_RATIOS, BlockHeight(0), noopBoundaries, includeLocalChannelCost = false, blip18InboundFees = true) + assert(fourShortestPaths.size == 4) + assert(hops2Ids(fourShortestPaths(0).path.map(graphEdgeToHop)) == 2 :: 5 :: Nil) // D -> E -> F + assert(hops2Ids(fourShortestPaths(1).path.map(graphEdgeToHop)) == 1 :: 3 :: 5 :: Nil) // D -> A -> E -> F + assert(hops2Ids(fourShortestPaths(2).path.map(graphEdgeToHop)) == 2 :: 4 :: 6 :: 7 :: Nil) // D -> E -> B -> C -> F + assert(hops2Ids(fourShortestPaths(3).path.map(graphEdgeToHop)) == 1 :: 3 :: 4 :: 6 :: 7 :: Nil) // D -> A -> E -> B -> C -> F + + // Update balance D -> A to evict the last path (balance too low) + val g2 = g1.addEdge(makeEdge(1L, d, a, 1 msat, 0, balance_opt = Some(DEFAULT_AMOUNT_MSAT + 3.msat))) + val threeShortestPaths = Graph.yenKshortestPaths(g2, d, f, DEFAULT_AMOUNT_MSAT, Set.empty, Set.empty, Set.empty, pathsToFind = 4, NO_WEIGHT_RATIOS, BlockHeight(0), noopBoundaries, includeLocalChannelCost = false, blip18InboundFees = true) + assert(threeShortestPaths.size == 3) + assert(hops2Ids(threeShortestPaths(0).path.map(graphEdgeToHop)) == 2 :: 5 :: Nil) // D -> E -> F + assert(hops2Ids(threeShortestPaths(1).path.map(graphEdgeToHop)) == 1 :: 3 :: 5 :: Nil) // D -> A -> E -> F + assert(hops2Ids(threeShortestPaths(2).path.map(graphEdgeToHop)) == 2 :: 4 :: 6 :: 7 :: Nil) // D -> E -> B -> C -> F + } + + test("find the k shortest path (wikipedia example)") { + val (c, d, e, f, g, h) = ( + PublicKey(hex"02999fa724ec3c244e4da52b4a91ad421dc96c9a810587849cd4b2469313519c73"), + PublicKey(hex"03f1cb1af20fe9ccda3ea128e27d7c39ee27375c8480f11a87c17197e97541ca6a"), + PublicKey(hex"0358e32d245ff5f5a3eb14c78c6f69c67cea7846bdf9aeeb7199e8f6fbb0306484"), + PublicKey(hex"029e059b6780f155f38e83601969919aae631ddf6faed58fe860c72225eb327d7c"), + PublicKey(hex"02f38f4e37142cc05df44683a83e22dea608cf4691492829ff4cf99888c5ec2d3a"), + PublicKey(hex"03fc5b91ce2d857f146fd9b986363374ffe04dc143d8bcd6d7664c8873c463cdfc") + ) + + val graph = GraphWithBalanceEstimates(DirectedGraph(Seq( + makeEdge(10L, c, e, 2 msat, 0), + makeEdge(20L, c, d, 3 msat, 0), + makeEdge(30L, d, f, 4 msat, 5), // D- > F has a higher cost to distinguish it from the 2nd cheapest route + makeEdge(40L, e, d, 1 msat, 0), + makeEdge(50L, e, f, 2 msat, 0), + makeEdge(60L, e, g, 3 msat, 0), + makeEdge(70L, f, g, 2 msat, 0), + makeEdge(80L, f, h, 1 msat, 0), + makeEdge(90L, g, h, 2 msat, 0) + )), 1 day) + + val twoShortestPaths = Graph.yenKshortestPaths(graph, c, h, DEFAULT_AMOUNT_MSAT, Set.empty, Set.empty, Set.empty, pathsToFind = 2, NO_WEIGHT_RATIOS, BlockHeight(0), noopBoundaries, includeLocalChannelCost = false, blip18InboundFees = true) + + assert(twoShortestPaths.size == 2) + val shortest = twoShortestPaths(0) + assert(hops2Ids(shortest.path.map(graphEdgeToHop)) == 10 :: 50 :: 80 :: Nil) // C -> E -> F -> H + + val secondShortest = twoShortestPaths(1) + assert(hops2Ids(secondShortest.path.map(graphEdgeToHop)) == 10 :: 60 :: 90 :: Nil) // C -> E -> G -> H + } + + test("terminate looking for k-shortest path if there are no more alternative paths than k, must not consider routes going back on their steps") { + val f = randomKey().publicKey + + // simple graph with only 2 possible paths from A to F + val graph = GraphWithBalanceEstimates(DirectedGraph(Seq( + makeEdge(1L, a, b, 1 msat, 0), + makeEdge(1L, b, a, 1 msat, 0), + makeEdge(2L, b, c, 1 msat, 0), + makeEdge(2L, c, b, 1 msat, 0), + makeEdge(3L, c, f, 1 msat, 0), + makeEdge(3L, f, c, 1 msat, 0), + makeEdge(4L, c, d, 1 msat, 0), + makeEdge(4L, d, c, 1 msat, 0), + makeEdge(41L, d, c, 1 msat, 0), // there is more than one D -> C channel + makeEdge(5L, d, e, 1 msat, 0), + makeEdge(5L, e, d, 1 msat, 0), + makeEdge(6L, e, f, 1 msat, 0), + makeEdge(6L, f, e, 1 msat, 0) + )), 1 day) + + // we ask for 3 shortest paths but only 2 can be found + val foundPaths = Graph.yenKshortestPaths(graph, a, f, DEFAULT_AMOUNT_MSAT, Set.empty, Set.empty, Set.empty, pathsToFind = 3, NO_WEIGHT_RATIOS, BlockHeight(0), noopBoundaries, includeLocalChannelCost = false, blip18InboundFees = true) + assert(foundPaths.size == 2) + assert(hops2Ids(foundPaths(0).path.map(graphEdgeToHop)) == 1 :: 2 :: 3 :: Nil) // A -> B -> C -> F + assert(hops2Ids(foundPaths(1).path.map(graphEdgeToHop)) == 1 :: 2 :: 4 :: 5 :: 6 :: Nil) // A -> B -> C -> D -> E -> F + } + + test("select a random route below the requested fee") { + val strictFeeParams = DEFAULT_ROUTE_PARAMS + .modify(_.boundaries.maxFeeFlat).setTo(7 msat) + .modify(_.boundaries.maxFeeProportional).setTo(0) + .modify(_.randomize).setTo(true) + .modify(_.mpp.splittingStrategy).setTo(Randomize) + val strictFee = strictFeeParams.getMaxFee(DEFAULT_AMOUNT_MSAT) + assert(strictFee == 7.msat) + + // A -> B -> C -> D has total cost of 10000005 + // A -> E -> C -> D has total cost of 10000103 !! + // A -> E -> F -> D has total cost of 10000006 + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, feeBase = 1 msat, 0), + makeEdge(2L, b, c, feeBase = 2 msat, 0), + makeEdge(3L, c, d, feeBase = 3 msat, 0), + makeEdge(4L, a, e, feeBase = 1 msat, 0), + makeEdge(5L, e, f, feeBase = 3 msat, 0), + makeEdge(6L, f, d, feeBase = 3 msat, 0), + makeEdge(7L, e, c, feeBase = 100 msat, 0) + )), 1 day) + + for (_ <- 0 to 10) { + val Success(routes) = findRoute(g, a, d, DEFAULT_AMOUNT_MSAT, strictFee, numRoutes = 3, routeParams = strictFeeParams, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(routes.length == 2, routes) + val weightedPath = Graph.pathWeight(g.balances, a, route2Edges(routes.head), DEFAULT_AMOUNT_MSAT, BlockHeight(400000), NO_WEIGHT_RATIOS, includeLocalChannelCost = false, g.graph, enableInboundFees = false) + val totalFees = weightedPath.amount - DEFAULT_AMOUNT_MSAT + // over the three routes we could only get the 2 cheapest because the third is too expensive (over 7 msat of fees) + assert(totalFees == 5.msat || totalFees == 6.msat) + assert(weightedPath.length == 3) + } + } + + test("use weight ratios when computing the edge weight") { + val defaultCapacity = 15000 sat + val largeCapacity = 8000000 sat + + // A -> B -> C -> D is 'fee optimized', lower fees route (totFees = 2, totCltv = 4000) + // A -> E -> F -> D is 'timeout optimized', lower CLTV route (totFees = 3, totCltv = 18) + // A -> E -> C -> D is 'capacity optimized', more recent channel/larger capacity route + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, feeBase = 0 msat, 1000, minHtlc = 0 msat, capacity = defaultCapacity, cltvDelta = CltvExpiryDelta(13)), + makeEdge(4L, a, e, feeBase = 0 msat, 1000, minHtlc = 0 msat, capacity = defaultCapacity, cltvDelta = CltvExpiryDelta(12)), + makeEdge(2L, b, c, feeBase = 1 msat, 1000, minHtlc = 0 msat, capacity = defaultCapacity, cltvDelta = CltvExpiryDelta(500)), + makeEdge(3L, c, d, feeBase = 1 msat, 1000, minHtlc = 0 msat, capacity = defaultCapacity, cltvDelta = CltvExpiryDelta(500)), + makeEdge(5L, e, f, feeBase = 2 msat, 1000, minHtlc = 0 msat, capacity = defaultCapacity, cltvDelta = CltvExpiryDelta(9)), + makeEdge(6L, f, d, feeBase = 2 msat, 1000, minHtlc = 0 msat, capacity = defaultCapacity, cltvDelta = CltvExpiryDelta(9)), + makeEdge(7L, e, c, feeBase = 2 msat, 1000, minHtlc = 0 msat, capacity = largeCapacity, cltvDelta = CltvExpiryDelta(12)) + )), 1 day) + + val Success(routeFeeOptimized :: Nil) = findRoute(g, a, d, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(route2Nodes(routeFeeOptimized) == (a, b) :: (b, c) :: (c, d) :: Nil) + + val Success(routeCltvOptimized :: Nil) = findRoute(g, a, d, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(heuristics = HeuristicsConstants( + lockedFundsRisk = 1, + failureFees = RelayFees(0 msat, 0), + hopFees = RelayFees(0 msat, 0), + useLogProbability = false, + usePastRelaysData = false, + )), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(route2Nodes(routeCltvOptimized) == (a, e) :: (e, f) :: (f, d) :: Nil) + + val Success(routeCapacityOptimized :: Nil) = findRoute(g, a, d, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(heuristics = HeuristicsConstants( + lockedFundsRisk = 0, + failureFees = RelayFees(1000 msat, 1000), + hopFees = RelayFees(0 msat, 0), + useLogProbability = false, + usePastRelaysData = false, + )), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(route2Nodes(routeCapacityOptimized) == (a, e) :: (e, c) :: (c, d) :: Nil) + } + + test("prefer a route with a smaller total CLTV if fees and score are the same") { + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1, a, b, feeBase = 1 msat, 0, minHtlc = 0 msat, maxHtlc = None, cltvDelta = CltvExpiryDelta(12)), + makeEdge(4, a, e, feeBase = 1 msat, 0, minHtlc = 0 msat, maxHtlc = None, cltvDelta = CltvExpiryDelta(12)), + makeEdge(2, b, c, feeBase = 1 msat, 0, minHtlc = 0 msat, maxHtlc = None, cltvDelta = CltvExpiryDelta(10)), // smaller CLTV + makeEdge(3, c, d, feeBase = 1 msat, 0, minHtlc = 0 msat, maxHtlc = None, cltvDelta = CltvExpiryDelta(12)), + makeEdge(5, e, f, feeBase = 1 msat, 0, minHtlc = 0 msat, maxHtlc = None, cltvDelta = CltvExpiryDelta(12)), + makeEdge(6, f, d, feeBase = 1 msat, 0, minHtlc = 0 msat, maxHtlc = None, cltvDelta = CltvExpiryDelta(12)) + )), 1 day) + + val Success(routeScoreOptimized :: Nil) = findRoute(g, a, d, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(heuristics = HeuristicsConstants( + lockedFundsRisk = 1e-7, + failureFees = RelayFees(100 msat, 100), + hopFees = RelayFees(0 msat, 0), + useLogProbability = false, + usePastRelaysData = false, + )), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + + assert(route2Nodes(routeScoreOptimized) == (a, b) :: (b, c) :: (c, d) :: Nil) + } + + test("avoid a route that breaks off the max CLTV") { + // A -> B -> C -> D is cheaper but has a total CLTV > 2016! + // A -> E -> F -> D is more expensive but has a total CLTV < 2016 + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1, a, b, feeBase = 1 msat, 0, minHtlc = 0 msat, maxHtlc = None, cltvDelta = CltvExpiryDelta(144)), + makeEdge(4, a, e, feeBase = 100 msat, 0, minHtlc = 0 msat, maxHtlc = None, cltvDelta = CltvExpiryDelta(144)), + makeEdge(2, b, c, feeBase = 1 msat, 0, minHtlc = 0 msat, maxHtlc = None, cltvDelta = CltvExpiryDelta(1000)), + makeEdge(3, c, d, feeBase = 1 msat, 0, minHtlc = 0 msat, maxHtlc = None, cltvDelta = CltvExpiryDelta(900)), + makeEdge(5, e, f, feeBase = 100 msat, 0, minHtlc = 0 msat, maxHtlc = None, cltvDelta = CltvExpiryDelta(144)), + makeEdge(6, f, d, feeBase = 100 msat, 0, minHtlc = 0 msat, maxHtlc = None, cltvDelta = CltvExpiryDelta(144)) + )), 1 day) + + val Success(routeScoreOptimized :: Nil) = findRoute(g, a, d, DEFAULT_AMOUNT_MSAT / 2, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(heuristics = HeuristicsConstants( + lockedFundsRisk = 0, + failureFees = RelayFees(100 msat, 100), + hopFees = RelayFees(500 msat, 200), + useLogProbability = false, + usePastRelaysData = false, + )), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + + assert(route2Nodes(routeScoreOptimized) == (a, e) :: (e, f) :: (f, d) :: Nil) + } + + test("validate path fees") { + val ab = makeEdge(1L, a, b, feeBase = 100 msat, 10000, minHtlc = 150 msat, maxHtlc = Some(300 msat), capacity = 1 sat, balance_opt = Some(260 msat)) + val bc = makeEdge(10L, b, c, feeBase = 5 msat, 10000, minHtlc = 100 msat, maxHtlc = Some(400 msat), capacity = 1 sat) + val cd = makeEdge(20L, c, d, feeBase = 5 msat, 10000, minHtlc = 50 msat, maxHtlc = Some(500 msat), capacity = 1 sat) + + assert(Graph.validatePath(Nil, 200 msat)) // ok + assert(Graph.validatePath(Seq(ab), 260 msat)) // ok + assert(!Graph.validatePath(Seq(ab), 10000 msat)) // above max-htlc + assert(Graph.validatePath(Seq(ab, bc), 250 msat)) // ok + assert(!Graph.validatePath(Seq(ab, bc), 255 msat)) // above balance (AB) + assert(Graph.validatePath(Seq(ab, bc, cd), 200 msat)) // ok + assert(!Graph.validatePath(Seq(ab, bc, cd), 25 msat)) // below min-htlc (CD) + assert(!Graph.validatePath(Seq(ab, bc, cd), 60 msat)) // below min-htlc (BC) + assert(!Graph.validatePath(Seq(ab, bc, cd), 110 msat)) // below min-htlc (AB) + assert(!Graph.validatePath(Seq(ab, bc, cd), 550 msat)) // above max-htlc (CD) + assert(!Graph.validatePath(Seq(ab, bc, cd), 450 msat)) // above max-htlc (BC) + assert(!Graph.validatePath(Seq(ab, bc, cd), 350 msat)) // above max-htlc (AB) + assert(!Graph.validatePath(Seq(ab, bc, cd), 250 msat)) // above balance (AB) + } + + test("calculate multipart route to neighbor (many channels, known balance)") { + val amount = 60000 msat + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, 50 msat, 100, minHtlc = 1 msat, balance_opt = Some(15000 msat)), + makeEdge(2L, a, b, 15 msat, 10, minHtlc = 1 msat, balance_opt = Some(21000 msat)), + makeEdge(3L, a, b, 1 msat, 50, minHtlc = 1 msat, balance_opt = Some(17000 msat)), + makeEdge(4L, a, b, 100 msat, 20, minHtlc = 1 msat, balance_opt = Some(16000 msat)), + )), 1 day) + // 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, DEFAULT_ROUTE_PARAMS.mpp.splittingStrategy)) + + { + val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(routes.length == 4, routes) + assert(routes.forall(_.hops.length == 1), routes) + checkRouteAmounts(routes, amount, 0 msat) + } + { + val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = routeParams.copy(randomize = true).modify(_.mpp.splittingStrategy).setTo(Randomize), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(routes.length >= 4, routes) + assert(routes.forall(_.hops.length == 1), routes) + checkRouteAmounts(routes, amount, 0 msat) + } + { + val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = routeParams.modify(_.mpp.splittingStrategy).setTo(MaxExpectedAmount), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(routes.length >= 4, routes) + assert(routes.forall(_.hops.length == 1), routes) + checkRouteAmounts(routes, amount, 0 msat) + } + { + // 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, routeParams.mpp.splittingStrategy)), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(failure == Failure(RouteNotFound)) + } + } + + test("calculate multipart route to neighbor (single channel, known balance)") { + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, 50 msat, 100, minHtlc = 1 msat, balance_opt = Some(25000 msat)), + makeEdge(2L, a, c, 1 msat, 0, minHtlc = 1 msat, balance_opt = Some(50000 msat)), + makeEdge(3L, c, b, 1 msat, 0, minHtlc = 1 msat), + makeEdge(4L, a, d, 1 msat, 0, minHtlc = 1 msat, balance_opt = Some(45000 msat)), + )), 1 day) + + val amount = 25000 msat + val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(routes.length == 1, routes) + checkRouteAmounts(routes, amount, 0 msat) + assert(route2Ids(routes.head) == 1L :: Nil) + } + + test("calculate multipart route to neighbor (many channels, some balance unknown)") { + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, 50 msat, 100, minHtlc = 1 msat, balance_opt = Some(15000 msat)), + makeEdge(2L, a, b, 15 msat, 10, minHtlc = 1 msat, balance_opt = Some(25000 msat)), + makeEdge(3L, a, b, 1 msat, 50, minHtlc = 1 msat, balance_opt = None, capacity = 20 sat), + makeEdge(4L, a, b, 100 msat, 20, minHtlc = 1 msat, balance_opt = Some(10000 msat)), + makeEdge(5L, a, d, 1 msat, 0, minHtlc = 1 msat, balance_opt = Some(45000 msat)), + )), 1 day) + + val amount = 65000 msat + val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(routes.length == 4, routes) + assert(routes.forall(_.hops.length == 1), routes) + checkRouteAmounts(routes, amount, 0 msat) + } + + test("calculate multipart route to neighbor (many channels, some empty)") { + val amount = 35000 msat + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, 50 msat, 100, minHtlc = 1 msat, balance_opt = Some(15000 msat)), + makeEdge(2L, a, b, 15 msat, 10, minHtlc = 1 msat, balance_opt = Some(0 msat)), + makeEdge(3L, a, b, 1 msat, 50, minHtlc = 1 msat, balance_opt = None, capacity = 15 sat), + makeEdge(4L, a, b, 1 msat, 0, minHtlc = 0 msat, balance_opt = Some(0 msat)), + makeEdge(5L, a, b, 100 msat, 20, minHtlc = 1 msat, balance_opt = Some(10000 msat)), + makeEdge(6L, a, d, 1 msat, 0, minHtlc = 1 msat, balance_opt = Some(45000 msat)), + makeEdge(7L, a, d, 0 msat, 0, minHtlc = 0 msat, balance_opt = Some(0 msat)), + )), 1 day) + + { + val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(routes.length == 3, routes) + assert(routes.forall(_.hops.length == 1), routes) + checkIgnoredChannels(routes, 2L) + checkRouteAmounts(routes, amount, 0 msat) + } + { + val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true).modify(_.mpp.splittingStrategy).setTo(Randomize), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(routes.length >= 3, routes) + assert(routes.forall(_.hops.length == 1), routes) + checkIgnoredChannels(routes, 2L) + checkRouteAmounts(routes, amount, 0 msat) + } + { + val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS.modify(_.mpp.splittingStrategy).setTo(MaxExpectedAmount), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(routes.length >= 3, routes) + assert(routes.forall(_.hops.length == 1), routes) + checkIgnoredChannels(routes, 2L) + checkRouteAmounts(routes, amount, 0 msat) + } + } + + test("calculate multipart route to neighbor (ignored channels)") { + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, 50 msat, 100, minHtlc = 1 msat, balance_opt = Some(15000 msat)), + makeEdge(2L, a, b, 15 msat, 10, minHtlc = 1 msat, balance_opt = Some(25000 msat)), + makeEdge(3L, a, b, 1 msat, 50, minHtlc = 1 msat, balance_opt = None, capacity = 50 sat), + makeEdge(4L, a, b, 100 msat, 20, minHtlc = 1 msat, balance_opt = Some(10000 msat)), + makeEdge(5L, a, b, 1 msat, 10, minHtlc = 1 msat, balance_opt = None, capacity = 10 sat), + makeEdge(6L, a, d, 1 msat, 0, minHtlc = 1 msat, balance_opt = Some(45000 msat)), + )), 1 day) + + val amount = 20000 msat + val ignoredEdges = Set(ChannelDesc(ShortChannelId(2L), a, b), ChannelDesc(ShortChannelId(3L), a, b)) + val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, ignoredEdges = ignoredEdges, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(routes.forall(_.hops.length == 1), routes) + checkIgnoredChannels(routes, 2L, 3L) + checkRouteAmounts(routes, amount, 0 msat) + } + + test("calculate multipart route to neighbor (pending htlcs ignored for local channels)") { + val edge_ab_1 = makeEdge(1L, a, b, 50 msat, 100, minHtlc = 1 msat, balance_opt = Some(15000 msat)) + val edge_ab_2 = makeEdge(2L, a, b, 15 msat, 10, minHtlc = 1 msat, balance_opt = Some(25000 msat)) + val edge_ab_3 = makeEdge(3L, a, b, 1 msat, 50, minHtlc = 1 msat, balance_opt = None, capacity = 15 sat) + val g = GraphWithBalanceEstimates(DirectedGraph(List( + edge_ab_1, + edge_ab_2, + edge_ab_3, + makeEdge(4L, a, d, 1 msat, 0, minHtlc = 1 msat, balance_opt = Some(45000 msat)), + )), 1 day) + + val amount = 50000 msat + // These pending HTLCs will have already been taken into account in the edge's `balance_opt` field: findMultiPartRoute + // should ignore this information. + val pendingHtlcs = Seq(Route(10000 msat, graphEdgeToHop(edge_ab_1) :: Nil, None), Route(5000 msat, graphEdgeToHop(edge_ab_2) :: Nil, None)) + val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, pendingHtlcs = pendingHtlcs, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(routes.forall(_.hops.length == 1), routes) + checkRouteAmounts(routes, amount, 0 msat) + } + + test("calculate multipart route to neighbor (restricted htlc_maximum_msat)") { + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, 25 msat, 15, minHtlc = 1 msat, maxHtlc = Some(5000 msat), balance_opt = Some(18000 msat)), + makeEdge(2L, a, b, 15 msat, 10, minHtlc = 1 msat, maxHtlc = Some(5000 msat), balance_opt = Some(23000 msat)), + makeEdge(3L, a, b, 1 msat, 50, minHtlc = 1 msat, maxHtlc = Some(5000 msat), balance_opt = Some(21000 msat)), + makeEdge(4L, a, d, 1 msat, 0, minHtlc = 1 msat, balance_opt = Some(45000 msat)), + )), 1 day) + + val amount = 50000 msat + val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(routes.forall(_.hops.length == 1), routes) + assert(routes.length >= 10, routes) + assert(routes.forall(_.amount <= 5000.msat), routes) + checkRouteAmounts(routes, amount, 0 msat) + } + + test("calculate multipart route to neighbor (restricted htlc_minimum_msat)") { + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, 25 msat, 15, minHtlc = 2500 msat, balance_opt = Some(18000 msat)), + makeEdge(2L, a, b, 15 msat, 10, minHtlc = 2500 msat, balance_opt = Some(7000 msat)), + makeEdge(3L, a, b, 1 msat, 50, minHtlc = 2500 msat, balance_opt = Some(10000 msat)), + makeEdge(4L, a, d, 1 msat, 0, minHtlc = 1 msat, balance_opt = Some(45000 msat)), + )), 1 day) + + val amount = 30000 msat + 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), blip18InboundFees = true) + assert(routes.forall(_.hops.length == 1), routes) + assert(routes.length == 3, routes) + checkRouteAmounts(routes, amount, 0 msat) + } + + test("calculate multipart route to neighbor (through remote channels)") { + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, 25 msat, 15, minHtlc = 1000 msat, balance_opt = Some(18000 msat)), + makeEdge(2L, a, b, 15 msat, 10, minHtlc = 1000 msat, balance_opt = Some(7000 msat)), + makeEdge(3L, a, c, 1000 msat, 10000, minHtlc = 1000 msat, balance_opt = Some(10000 msat)), + makeEdge(4L, c, b, 10 msat, 1000, minHtlc = 1000 msat), + makeEdge(5L, a, d, 1 msat, 0, minHtlc = 1 msat, balance_opt = Some(25000 msat)), + )), 1 day) + + val amount = 30000 msat + val maxFeeTooLow = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(maxFeeTooLow == Failure(RouteNotFound)) + + val Success(routes) = findMultiPartRoute(g, a, b, amount, 20 msat, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(routes.forall(_.hops.length <= 2), routes) + assert(routes.length == 3, routes) + checkRouteAmounts(routes, amount, 20 msat) + } + + test("cannot find multipart route to neighbor (not enough balance)") { + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, 0 msat, 0, minHtlc = 1 msat, balance_opt = Some(15000 msat)), + makeEdge(2L, a, b, 0 msat, 0, minHtlc = 1 msat, balance_opt = Some(5000 msat)), + makeEdge(3L, a, b, 0 msat, 0, minHtlc = 1 msat, balance_opt = Some(10000 msat)), + makeEdge(4L, a, d, 0 msat, 0, minHtlc = 1 msat, balance_opt = Some(45000 msat)), + )), 1 day) + + { + val result = findMultiPartRoute(g, a, b, 40000 msat, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(result == Failure(RouteNotFound)) + } + { + val result = findMultiPartRoute(g, a, b, 40000 msat, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true).modify(_.mpp.splittingStrategy).setTo(Randomize), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(result == Failure(RouteNotFound)) + } + { + val result = findMultiPartRoute(g, a, b, 40000 msat, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS.modify(_.mpp.splittingStrategy).setTo(MaxExpectedAmount), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(result == Failure(RouteNotFound)) + } + } + + test("cannot find multipart route to neighbor (not enough capacity)") { + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, 0 msat, 0, minHtlc = 1 msat, capacity = 1500 sat), + makeEdge(2L, a, b, 0 msat, 0, minHtlc = 1 msat, capacity = 2000 sat), + makeEdge(3L, a, b, 0 msat, 0, minHtlc = 1 msat, capacity = 1200 sat), + makeEdge(4L, a, d, 0 msat, 0, minHtlc = 1 msat, capacity = 4500 sat), + )), 1 day) + + val result = findMultiPartRoute(g, a, b, 5000000 msat, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(result == Failure(RouteNotFound)) + } + + test("cannot find multipart route to neighbor (restricted htlc_minimum_msat)") { + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, 25 msat, 15, minHtlc = 5000 msat, balance_opt = Some(6000 msat)), + makeEdge(2L, a, b, 15 msat, 10, minHtlc = 5000 msat, balance_opt = Some(7000 msat)), + makeEdge(3L, a, d, 0 msat, 0, minHtlc = 5000 msat, balance_opt = Some(9000 msat)), + )), 1 day) + + { + val result = findMultiPartRoute(g, a, b, 10000 msat, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(result == Failure(RouteNotFound)) + } + { + val result = findMultiPartRoute(g, a, b, 10000 msat, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true).modify(_.mpp.splittingStrategy).setTo(Randomize), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(result == Failure(RouteNotFound)) + } + { + val result = findMultiPartRoute(g, a, b, 10000 msat, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS.modify(_.mpp.splittingStrategy).setTo(MaxExpectedAmount), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(result == Failure(RouteNotFound)) + } + } + + test("calculate multipart route to remote node (many local channels)") { + // +-------+ + // | | + // A ----- C ----- E + // | | + // +--- B --- D ---+ + val (amount, maxFee) = (30000 msat, 150 msat) + val edge_ab = makeEdge(1L, a, b, 50 msat, 100, minHtlc = 1 msat, balance_opt = Some(15000 msat)) + val g = GraphWithBalanceEstimates(DirectedGraph(List( + edge_ab, + makeEdge(2L, b, d, 15 msat, 0, minHtlc = 1 msat, capacity = 25 sat), + makeEdge(3L, d, e, 15 msat, 0, minHtlc = 0 msat, capacity = 20 sat), + makeEdge(4L, a, c, 1 msat, 50, minHtlc = 1 msat, balance_opt = Some(10000 msat)), + makeEdge(5L, a, c, 1 msat, 50, minHtlc = 1 msat, balance_opt = Some(8000 msat)), + makeEdge(6L, c, e, 50 msat, 30, minHtlc = 1 msat, capacity = 20 sat), + )), 1 day) + + { + val Success(routes) = findMultiPartRoute(g, a, e, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + checkRouteAmounts(routes, amount, maxFee) + assert(routes2Ids(routes) == Set(Seq(1L, 2L, 3L), Seq(4L, 6L), Seq(5L, 6L))) + } + { + // Update A - B with unknown balance, capacity should be used instead. + val g1 = g.addEdge(makeEdge(1L, a, b, 50 msat, 100, minHtlc = 1 msat, capacity = 15 sat, balance_opt = None)) + val Success(routes) = findMultiPartRoute(g1, a, e, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + checkRouteAmounts(routes, amount, maxFee) + assert(routes2Ids(routes) == Set(Seq(1L, 2L, 3L), Seq(4L, 6L), Seq(5L, 6L))) + } + { + // Randomize routes. + val Success(routes) = findMultiPartRoute(g, a, e, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true).modify(_.mpp.splittingStrategy).setTo(Randomize), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + checkRouteAmounts(routes, amount, maxFee) + } + { + val Success(routes) = findMultiPartRoute(g, a, e, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS.modify(_.mpp.splittingStrategy).setTo(MaxExpectedAmount), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + checkRouteAmounts(routes, amount, maxFee) + } + { + // Update balance A - B to be too low. + val g1 = g.addEdge(edge_ab.copy(balance_opt = Some(2000 msat))) + val failure = findMultiPartRoute(g1, a, e, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(failure == Failure(RouteNotFound)) + } + { + // Update capacity A - B to be too low. + val g1 = g.addEdge(makeEdge(1L, a, b, 50 msat, 100, minHtlc = 1 msat, capacity = 5 sat, balance_opt = None)) + val failure = findMultiPartRoute(g1, a, e, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(failure == Failure(RouteNotFound)) + } + { + // Try to find a route with a maxFee that's too low. + val maxFeeTooLow = 100 msat + val failure = findMultiPartRoute(g, a, e, amount, maxFeeTooLow, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(failure == Failure(RouteNotFound)) + } + } + + test("calculate multipart route to remote node (tiny amount)") { + // A ----- C ----- E + // | | + // +--- 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, DEFAULT_ROUTE_PARAMS.mpp.splittingStrategy)) + val g = GraphWithBalanceEstimates(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), + makeEdge(3L, d, e, 15 msat, 0, minHtlc = 1 msat, capacity = 20 sat), + makeEdge(4L, a, c, 1 msat, 50, minHtlc = 1 msat, balance_opt = Some(1000 msat)), + makeEdge(5L, c, e, 50 msat, 30, minHtlc = 1 msat, capacity = 20 sat), + )), 1 day) + + { + // We can send single-part tiny payments. + val (amount, maxFee) = (1400 msat, 30 msat) + val Success(routes) = findMultiPartRoute(g, a, e, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + checkRouteAmounts(routes, amount, maxFee) + } + { + // But we don't want to split such tiny amounts. + val (amount, maxFee) = (2000 msat, 150 msat) + val failure = findMultiPartRoute(g, a, e, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(failure == Failure(RouteNotFound)) + } + } + + test("calculate multipart route to remote node (single path)") { + val (amount, maxFee) = (100000 msat, 500 msat) + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, 50 msat, 100, minHtlc = 1 msat, balance_opt = Some(500000 msat)), + makeEdge(2L, b, c, 10 msat, 30, minHtlc = 1 msat, capacity = 150 sat), + makeEdge(3L, c, d, 15 msat, 50, minHtlc = 1 msat, capacity = 150 sat), + )), 1 day) + + val Success(routes) = findMultiPartRoute(g, a, d, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + checkRouteAmounts(routes, amount, maxFee) + assert(routes.length == 1, "payment shouldn't be split when we have one path with enough capacity") + assert(routes2Ids(routes) == Set(Seq(1L, 2L, 3L))) + } + + test("calculate multipart route to remote node (single local channel)") { + // +--- C ---+ + // | | + // A --- B ------- D --- F + // | | + // +----- E -------+ + val (amount, maxFee) = (400000 msat, 250 msat) + val edge_ab = makeEdge(1L, a, b, 50 msat, 100, minHtlc = 1 msat, balance_opt = Some(500000 msat)) + val g = GraphWithBalanceEstimates(DirectedGraph(List( + edge_ab, + makeEdge(2L, b, c, 10 msat, 30, minHtlc = 1 msat, capacity = 150 sat), + makeEdge(3L, c, d, 15 msat, 50, minHtlc = 1 msat, capacity = 150 sat), + makeEdge(4L, b, d, 20 msat, 75, minHtlc = 1 msat, capacity = 180 sat), + makeEdge(5L, d, f, 5 msat, 50, minHtlc = 1 msat, capacity = 300 sat), + makeEdge(6L, b, e, 15 msat, 80, minHtlc = 1 msat, capacity = 210 sat), + makeEdge(7L, e, f, 15 msat, 100, minHtlc = 1 msat, capacity = 200 sat), + )), 1 day) + + { + val Success(routes) = findMultiPartRoute(g, a, f, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + checkRouteAmounts(routes, amount, maxFee) + assert(routes2Ids(routes) == Set(Seq(1L, 2L, 3L, 5L), Seq(1L, 4L, 5L), Seq(1L, 6L, 7L))) + } + { + // Randomize routes. + val Success(routes) = findMultiPartRoute(g, a, f, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true).modify(_.mpp.splittingStrategy).setTo(Randomize), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + checkRouteAmounts(routes, amount, maxFee) + } + { + val Success(routes) = findMultiPartRoute(g, a, f, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS.modify(_.mpp.splittingStrategy).setTo(MaxExpectedAmount), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + checkRouteAmounts(routes, amount, maxFee) + } + { + // Update A - B with unknown balance, capacity should be used instead. + val g1 = g.addEdge(makeEdge(1L, a, b, 50 msat, 100, minHtlc = 1 msat, capacity = 500 sat, balance_opt = None)) + val Success(routes) = findMultiPartRoute(g1, a, f, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + checkRouteAmounts(routes, amount, maxFee) + assert(routes2Ids(routes) == Set(Seq(1L, 2L, 3L, 5L), Seq(1L, 4L, 5L), Seq(1L, 6L, 7L))) + } + { + // Update balance A - B to be too low to cover fees. + val g1 = g.addEdge(edge_ab.copy(balance_opt = Some(400000 msat))) + val failure = findMultiPartRoute(g1, a, f, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(failure == Failure(RouteNotFound)) + } + { + // Update capacity A - B to be too low to cover fees. + val g1 = g.addEdge(makeEdge(1L, a, b, 50 msat, 100, minHtlc = 1 msat, capacity = 400 sat, balance_opt = None)) + val failure = findMultiPartRoute(g1, a, f, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(failure == Failure(RouteNotFound)) + } + { + // Try to find a route with a maxFee that's too low. + val maxFeeTooLow = 100 msat + val failure = findMultiPartRoute(g, a, f, amount, maxFeeTooLow, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(failure == Failure(RouteNotFound)) + } + } + + test("calculate multipart route to remote node (ignore cheap routes with low capacity)") { + // + // +---> B1 -----+ + // | | + // +---> B2 -----+ + // | | + // +---> ... ----+ + // | | + // +---> B10 ----+ + // | | + // | v + // A ---> C ---> D + val cheapEdges = (1 to 10).flatMap(i => { + val bi = randomKey().publicKey + List( + makeEdge(2 * i, a, bi, 1 msat, 1, minHtlc = 1 msat, capacity = 1500 sat, balance_opt = Some(1_200_000 msat)), + makeEdge(2 * i + 1, bi, d, 1 msat, 1, minHtlc = 1 msat, capacity = 1500 sat), + ) + }) + val preferredEdges = List( + makeEdge(100, a, c, 5 msat, 1000, minHtlc = 1 msat, capacity = 25000 sat, balance_opt = Some(20_000_000 msat)), + makeEdge(101, c, d, 5 msat, 1000, minHtlc = 1 msat, capacity = 25000 sat), + ) + val g = GraphWithBalanceEstimates(DirectedGraph(preferredEdges ++ cheapEdges), 1 day) + + { + 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, FullCapacity)) + val Success(routes) = findMultiPartRoute(g, a, d, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + 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, FullCapacity)) + val failure = findMultiPartRoute(g, a, d, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + 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, FullCapacity)) + val Success(routes) = findMultiPartRoute(g, a, d, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(routes.length == 5) + routes.foreach(route => { + assert(route.hops.length == 2) + assert(route.amount <= 1_200_000.msat) + assert(!route.hops.flatMap(h => Seq(h.nodeId, h.nextNodeId)).contains(c)) + }) + } + } + + test("calculate multipart route to remote node (ignored channels and nodes)") { + // +----- B --xxx-- C -----+ + // | +-------- D --------+ | + // | | | | + // +---+ (empty x2) +---+ + // | A | --------------- | F | + // +---+ +---+ + // | | (not empty) | | + // | +-------------------+ | + // +---------- E ----------+ + val (amount, maxFee) = (25000 msat, 5 msat) + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, 1 msat, 0, minHtlc = 1 msat, balance_opt = Some(75000 msat)), + makeEdge(2L, b, c, 1 msat, 0, minHtlc = 1 msat, capacity = 150 sat), + makeEdge(3L, c, f, 1 msat, 0, minHtlc = 1 msat, capacity = 150 sat), + makeEdge(4L, a, d, 1 msat, 0, minHtlc = 1 msat, balance_opt = Some(85000 msat)), + makeEdge(5L, d, f, 1 msat, 0, minHtlc = 1 msat, capacity = 300 sat), + makeEdge(6L, a, f, 1 msat, 0, minHtlc = 1 msat, balance_opt = Some(0 msat)), + makeEdge(7L, a, f, 0 msat, 0, minHtlc = 0 msat, balance_opt = Some(0 msat)), + makeEdge(8L, a, f, 1 msat, 0, minHtlc = 1 msat, balance_opt = Some(10000 msat)), + makeEdge(9L, a, e, 1 msat, 0, minHtlc = 1 msat, balance_opt = Some(18000 msat)), + makeEdge(10L, e, f, 1 msat, 0, minHtlc = 1 msat, capacity = 15 sat), + )), 1 day) + + val ignoredNodes = Set(d) + val ignoredChannels = Set(ChannelDesc(ShortChannelId(2L), b, c)) + val Success(routes) = findMultiPartRoute(g, a, f, amount, maxFee, ignoredEdges = ignoredChannels, ignoredVertices = ignoredNodes, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + checkRouteAmounts(routes, amount, maxFee) + assert(routes2Ids(routes) == Set(Seq(8L), Seq(9L, 10L))) + } + + test("calculate multipart route to remote node (complex graph)") { + // +---+ +---+ +---+ + // | A |-----+ +--->| B |--->| C | + // +---+ | | +---+ +---+ + // ^ | +---+ | | + // | +--->| E |---+ | + // | | +---+ | | + // +---+ | | +---+ | + // | D |-----+ +--->| F |<-----+ + // +---+ +---+ + val g = GraphWithBalanceEstimates(DirectedGraph(Seq( + makeEdge(1L, d, a, 100 msat, 1000, minHtlc = 1000 msat, balance_opt = Some(80000 msat)), + makeEdge(2L, d, e, 100 msat, 1000, minHtlc = 1500 msat, balance_opt = Some(20000 msat)), + makeEdge(3L, a, e, 5 msat, 50, minHtlc = 1200 msat, capacity = 100 sat), + makeEdge(4L, e, f, 25 msat, 1000, minHtlc = 1300 msat, capacity = 25 sat), + makeEdge(5L, e, b, 10 msat, 100, minHtlc = 1100 msat, capacity = 75 sat), + 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) + )), 1 day) + val routeParams = DEFAULT_ROUTE_PARAMS.copy(mpp = MultiPartParams(1500 msat, 10, DEFAULT_ROUTE_PARAMS.mpp.splittingStrategy)) + + { + val (amount, maxFee) = (15000 msat, 50 msat) + val Success(routes) = findMultiPartRoute(g, d, f, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + checkRouteAmounts(routes, amount, maxFee) + } + { + val (amount, maxFee) = (25000 msat, 100 msat) + val Success(routes) = findMultiPartRoute(g, d, f, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + checkRouteAmounts(routes, amount, maxFee) + } + { + val (amount, maxFee) = (25000 msat, 50 msat) + val failure = findMultiPartRoute(g, d, f, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(failure == Failure(RouteNotFound)) + } + { + val (amount, maxFee) = (40000 msat, 100 msat) + val Success(routes) = findMultiPartRoute(g, d, f, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + checkRouteAmounts(routes, amount, maxFee) + } + { + val (amount, maxFee) = (40000 msat, 100 msat) + val Success(routes) = findMultiPartRoute(g, d, f, amount, maxFee, routeParams = routeParams.copy(randomize = true).modify(_.mpp.splittingStrategy).setTo(Randomize), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + checkRouteAmounts(routes, amount, maxFee) + } + { + val (amount, maxFee) = (40000 msat, 100 msat) + val Success(routes) = findMultiPartRoute(g, d, f, amount, maxFee, routeParams = routeParams.modify(_.mpp.splittingStrategy).setTo(MaxExpectedAmount), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + checkRouteAmounts(routes, amount, maxFee) + } + { + val (amount, maxFee) = (40000 msat, 50 msat) + val failure = findMultiPartRoute(g, d, f, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(failure == Failure(RouteNotFound)) + } + } + + test("calculate multipart route to remote node (with extra edges)") { + // +--- B ---+ + // A D (---) E (---) F + // +--- C ---+ + val (amount, maxFeeE, maxFeeF) = (10000 msat, 50 msat, 100 msat) + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, 1 msat, 0, minHtlc = 1 msat, maxHtlc = Some(4000 msat), balance_opt = Some(7000 msat)), + makeEdge(2L, b, d, 1 msat, 0, minHtlc = 1 msat, capacity = 50 sat), + makeEdge(3L, a, c, 1 msat, 0, minHtlc = 1 msat, maxHtlc = Some(4000 msat), balance_opt = Some(6000 msat)), + makeEdge(4L, c, d, 1 msat, 0, minHtlc = 1 msat, capacity = 40 sat), + )), 1 day) + val extraEdges = Set( + makeEdge(10L, d, e, 10 msat, 100, minHtlc = 500 msat, capacity = 15 sat), + makeEdge(11L, e, f, 5 msat, 100, minHtlc = 500 msat, capacity = 10 sat), + ) + + val Success(routes1) = findMultiPartRoute(g, a, e, amount, maxFeeE, extraEdges = extraEdges, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + checkRouteAmounts(routes1, amount, maxFeeE) + assert(routes1.length >= 3, routes1) + assert(routes1.forall(_.amount <= 4000.msat), routes1) + + val Success(routes2) = findMultiPartRoute(g, a, f, amount, maxFeeF, extraEdges = extraEdges, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + checkRouteAmounts(routes2, amount, maxFeeF) + assert(routes2.length >= 3, routes2) + assert(routes2.forall(_.amount <= 4000.msat), routes2) + + val maxFeeTooLow = 40 msat + val failure = findMultiPartRoute(g, a, f, amount, maxFeeTooLow, extraEdges = extraEdges, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(failure == Failure(RouteNotFound)) + } + + test("calculate multipart route to remote node (pending htlcs)") { + // +----- B -----+ + // | | + // A----- C ---- E + // | | + // +----- D -----+ + val (amount, maxFee) = (15000 msat, 100 msat) + val edge_ab = makeEdge(1L, a, b, 1 msat, 0, minHtlc = 100 msat, balance_opt = Some(5000 msat)) + val edge_be = makeEdge(2L, b, e, 1 msat, 0, minHtlc = 100 msat, capacity = 5 sat) + val g = GraphWithBalanceEstimates(DirectedGraph(List( + // The A -> B -> E route is the most economic one, but we already have a pending HTLC in it. + edge_ab, + edge_be, + makeEdge(3L, a, c, 50 msat, 0, minHtlc = 100 msat, balance_opt = Some(10000 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(10000 msat)), + makeEdge(6L, d, e, 50 msat, 0, minHtlc = 100 msat, capacity = 25 sat), + )), 1 day) + + val pendingHtlcs = Seq(Route(5000 msat, graphEdgeToHop(edge_ab) :: graphEdgeToHop(edge_be) :: Nil, None)) + val Success(routes) = findMultiPartRoute(g, a, e, amount, maxFee, pendingHtlcs = pendingHtlcs, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(routes.forall(_.hops.length == 2), routes) + checkRouteAmounts(routes, amount, maxFee) + checkIgnoredChannels(routes, 1L, 2L) + } + + test("calculate multipart route for full amount or fail", Tag("fuzzy")) { + // +------------------------------------+ + // | | + // | v + // +---+ +---+ +---+ + // | A |-----+ +--------->| B |--->| C | + // +---+ | | +---+ +---+ + // ^ | +---+ | + // | +--->| E |----------+ | + // | +---+ | | + // | ^ v | + // +---+ | +---+ | + // | D |------------+ | F |<-----+ + // +---+ +---+ + // | ^ + // | | + // +---------------------------+ + for (_ <- 1 to 100) { + val amount = (100 + Random.nextLong(200000)).msat + val maxFee = 50.msat.max(amount * 0.03) + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, d, f, Random.nextLong(250).msat, Random.nextInt(10000), minHtlc = Random.nextLong(100).msat, maxHtlc = Some((20000 + Random.nextLong(80000)).msat), CltvExpiryDelta(Random.nextInt(288)), capacity = (10 + Random.nextLong(100)).sat, balance_opt = Some(Random.nextLong(2 * amount.toLong).msat)), + makeEdge(2L, d, a, Random.nextLong(250).msat, Random.nextInt(10000), minHtlc = Random.nextLong(100).msat, maxHtlc = Some((20000 + Random.nextLong(80000)).msat), CltvExpiryDelta(Random.nextInt(288)), capacity = (10 + Random.nextLong(100)).sat, balance_opt = Some(Random.nextLong(2 * amount.toLong).msat)), + makeEdge(3L, d, e, Random.nextLong(250).msat, Random.nextInt(10000), minHtlc = Random.nextLong(100).msat, maxHtlc = Some((20000 + Random.nextLong(80000)).msat), CltvExpiryDelta(Random.nextInt(288)), capacity = (10 + Random.nextLong(100)).sat, balance_opt = Some(Random.nextLong(2 * amount.toLong).msat)), + makeEdge(4L, a, c, Random.nextLong(250).msat, Random.nextInt(10000), minHtlc = Random.nextLong(100).msat, maxHtlc = Some((20000 + Random.nextLong(80000)).msat), CltvExpiryDelta(Random.nextInt(288)), capacity = (10 + Random.nextLong(100)).sat), + makeEdge(5L, a, e, Random.nextLong(250).msat, Random.nextInt(10000), minHtlc = Random.nextLong(100).msat, maxHtlc = Some((20000 + Random.nextLong(80000)).msat), CltvExpiryDelta(Random.nextInt(288)), capacity = (10 + Random.nextLong(100)).sat), + makeEdge(6L, e, f, Random.nextLong(250).msat, Random.nextInt(10000), minHtlc = Random.nextLong(100).msat, maxHtlc = Some((20000 + Random.nextLong(80000)).msat), CltvExpiryDelta(Random.nextInt(288)), capacity = (10 + Random.nextLong(100)).sat), + makeEdge(7L, e, b, Random.nextLong(250).msat, Random.nextInt(10000), minHtlc = Random.nextLong(100).msat, maxHtlc = Some((20000 + Random.nextLong(80000)).msat), CltvExpiryDelta(Random.nextInt(288)), capacity = (10 + Random.nextLong(100)).sat), + makeEdge(8L, b, c, Random.nextLong(250).msat, Random.nextInt(10000), minHtlc = Random.nextLong(100).msat, maxHtlc = Some((20000 + Random.nextLong(80000)).msat), CltvExpiryDelta(Random.nextInt(288)), capacity = (10 + Random.nextLong(100)).sat), + makeEdge(9L, c, f, Random.nextLong(250).msat, Random.nextInt(10000), minHtlc = Random.nextLong(100).msat, maxHtlc = Some((20000 + Random.nextLong(80000)).msat), CltvExpiryDelta(Random.nextInt(288)), capacity = (10 + Random.nextLong(100)).sat) + )), 1 day) + + findMultiPartRoute(g, d, f, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true).modify(_.mpp.splittingStrategy).setTo(Randomize), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) match { + case Success(routes) => checkRouteAmounts(routes, amount, maxFee) + case Failure(ex) => assert(ex == RouteNotFound) + } + } + } + + 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 = GraphWithBalanceEstimates(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), + )), 1 day) + + 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), blip18InboundFees = true) + 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 = GraphWithBalanceEstimates(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), + )), 1 day) + + 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), blip18InboundFees = true) + 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") { + // +-----------------+ + // | | + // | v + // A --> B --> C --> D --> E + // ^ | + // | | + // F <---+ + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, 1000 msat, 1000), + makeEdge(2L, b, c, 1000 msat, 1000), + makeEdge(3L, c, d, 1000 msat, 1000), + makeEdge(4L, d, e, 1000 msat, 1000), + makeEdge(5L, b, e, 1000 msat, 1000), + makeEdge(6L, c, f, 1000 msat, 1000), + makeEdge(7L, f, b, 1000 msat, 1000), + )), 1 day) + + val Success(routes) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 3, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(routes.length == 2) + val route1 :: route2 :: Nil = routes + assert(route2Ids(route1) == 1 :: 5 :: Nil) + assert(route2Ids(route2) == 1 :: 2 :: 3 :: 4 :: Nil) + } + + test("reversed loop trap") { + // +-----------------+ + // | | + // v | + // A <-- B <-- C <-- D <-- E + // | ^ + // | | + // F ----+ + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, b, a, 1000 msat, 1000), + makeEdge(2L, c, b, 1000 msat, 1000), + makeEdge(3L, d, c, 1000 msat, 1000), + makeEdge(4L, e, d, 1000 msat, 1000), + makeEdge(5L, e, b, 1000 msat, 1000), + makeEdge(6L, f, c, 1000 msat, 1000), + makeEdge(7L, b, f, 1000 msat, 1000), + )), 1 day) + + val Success(routes) = findRoute(g, e, a, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 3, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(routes.length == 2) + val route1 :: route2 :: Nil = routes + assert(route2Ids(route1) == 5 :: 1 :: Nil) + assert(route2Ids(route2) == 4 :: 3 :: 2 :: 1 :: Nil) + } + + test("k-shortest paths must be distinct") { + // +----> N ---> N N ---> N ----+ + // / \ / \ / \ + // A +--+ (...) +--+ B + // \ / \ / \ / + // +----> N ---> N N ---> N ----+ + + def makeEdges(n: Int): Seq[GraphEdge] = { + val nodes = new Array[(PublicKey, PublicKey)](n) + for (i <- nodes.indices) { + nodes(i) = (randomKey().publicKey, randomKey().publicKey) + } + val q = new mutable.Queue[GraphEdge] + // One path is shorter to maximise the overlap between the n-shortest paths, they will all be like the shortest path with a single hop changed. + q.enqueue(makeEdge(1L, a, nodes(0)._1, 100 msat, 90)) + q.enqueue(makeEdge(2L, a, nodes(0)._2, 100 msat, 100)) + for (i <- 0 until (n - 1)) { + q.enqueue(makeEdge(4 * i + 3, nodes(i)._1, nodes(i + 1)._1, 100 msat, 90)) + q.enqueue(makeEdge(4 * i + 4, nodes(i)._1, nodes(i + 1)._2, 100 msat, 90)) + q.enqueue(makeEdge(4 * i + 5, nodes(i)._2, nodes(i + 1)._1, 100 msat, 100)) + q.enqueue(makeEdge(4 * i + 6, nodes(i)._2, nodes(i + 1)._2, 100 msat, 100)) + } + q.enqueue(makeEdge(4 * n, nodes(n - 1)._1, b, 100 msat, 90)) + q.enqueue(makeEdge(4 * n + 1, nodes(n - 1)._2, b, 100 msat, 100)) + q.toSeq + } + + val g = GraphWithBalanceEstimates(DirectedGraph(makeEdges(10)), 1 day) + + val Success(routes) = findRoute(g, a, b, DEFAULT_AMOUNT_MSAT, 100000000 msat, numRoutes = 10, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(routes.distinct.length == 10) + } + + test("all paths are shortest") { + // +----> N ---> N N ---> N ----+ + // / \ / \ / \ + // A +--+ (...) +--+ B + // \ / \ / \ / + // +----> N ---> N N ---> N ----+ + + def makeEdges(n: Int): Seq[GraphEdge] = { + val nodes = new Array[(PublicKey, PublicKey)](n) + for (i <- nodes.indices) { + nodes(i) = (randomKey().publicKey, randomKey().publicKey) + } + val q = new mutable.Queue[GraphEdge] + q.enqueue(makeEdge(1L, a, nodes(0)._1, 100 msat, 100)) + q.enqueue(makeEdge(2L, a, nodes(0)._2, 100 msat, 100)) + for (i <- 0 until (n - 1)) { + q.enqueue(makeEdge(4 * i + 3, nodes(i)._1, nodes(i + 1)._1, 100 msat, 100)) + q.enqueue(makeEdge(4 * i + 4, nodes(i)._1, nodes(i + 1)._2, 100 msat, 100)) + q.enqueue(makeEdge(4 * i + 5, nodes(i)._2, nodes(i + 1)._1, 100 msat, 100)) + q.enqueue(makeEdge(4 * i + 6, nodes(i)._2, nodes(i + 1)._2, 100 msat, 100)) + } + q.enqueue(makeEdge(4 * n, nodes(n - 1)._1, b, 100 msat, 100)) + q.enqueue(makeEdge(4 * n + 1, nodes(n - 1)._2, b, 100 msat, 100)) + q.toSeq + } + + val g = GraphWithBalanceEstimates(DirectedGraph(makeEdges(10)), 1 day) + + val Success(routes) = findRoute(g, a, b, DEFAULT_AMOUNT_MSAT, 100000000 msat, numRoutes = 10, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(routes.distinct.length == 10) + val fees = routes.map(_.channelFee(false)) + assert(fees.forall(_ == fees.head)) + } + + test("can't relay if fee is not sufficient") { + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, 1000 msat, 7000), + )), 1 day) + + assert(findRoute(g, a, b, 10000000 msat, 10000 msat, numRoutes = 3, routeParams = DEFAULT_ROUTE_PARAMS.copy(includeLocalChannelCost = true), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) == Failure(RouteNotFound)) + assert(findRoute(g, a, b, 10000000 msat, 100000 msat, numRoutes = 3, routeParams = DEFAULT_ROUTE_PARAMS.copy(includeLocalChannelCost = true), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true).isSuccess) + } + + test("penalty per hop") { + // S ---> A ---> B + // | ^ + // v | + // C ---> D + // | ^ + // v | + // E ---> F + val start = randomKey().publicKey + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(0L, start, a, 0 msat, 0), + makeEdge(1L, a, b, 1000 msat, 1000), + makeEdge(2L, a, c, 0 msat, 0), + makeEdge(3L, c, d, 700 msat, 1000), + makeEdge(4L, d, b, 0 msat, 0), + makeEdge(5L, c, e, 0 msat, 0), + makeEdge(6L, e, f, 600 msat, 1000), + makeEdge(7L, f, d, 0 msat, 0), + )), 1 day) + + { // No hop cost + val Success(routes) = findRoute(g, start, b, DEFAULT_AMOUNT_MSAT, 100000000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(routes.distinct.length == 1) + val route :: Nil = routes + assert(route2Ids(route) == 0 :: 2 :: 5 :: 6 :: 7 :: 4 :: Nil) + } + { // small base hop cost + val Success(routes) = findRoute(g, start, b, DEFAULT_AMOUNT_MSAT, 100000000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(heuristics = HeuristicsConstants(0, RelayFees(0 msat, 0), RelayFees(100 msat, 0), useLogProbability = false, usePastRelaysData = false)), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(routes.distinct.length == 1) + val route :: Nil = routes + assert(route2Ids(route) == 0 :: 2 :: 3 :: 4 :: Nil) + } + { // large proportional hop cost + val Success(routes) = findRoute(g, start, b, DEFAULT_AMOUNT_MSAT, 100000000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(heuristics = HeuristicsConstants(0, RelayFees(0 msat, 0), RelayFees(0 msat, 200), useLogProbability = false, usePastRelaysData = false)), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(routes.distinct.length == 1) + val route :: Nil = routes + assert(route2Ids(route) == 0 :: 1 :: Nil) + } + } + + test("most likely successful path") { + // S ---> A ---> B + // | ^ + // v | + // C ---> D + val start = randomKey().publicKey + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(0L, start, a, 0 msat, 0), + makeEdge(1L, a, b, 1000 msat, 1000, capacity = (DEFAULT_AMOUNT_MSAT * 1.2).truncateToSatoshi), + makeEdge(2L, a, c, 400 msat, 500, capacity = (DEFAULT_AMOUNT_MSAT * 3).truncateToSatoshi), + makeEdge(3L, c, d, 400 msat, 500, capacity = (DEFAULT_AMOUNT_MSAT * 3).truncateToSatoshi), + makeEdge(4L, d, b, 400 msat, 500, capacity = (DEFAULT_AMOUNT_MSAT * 3).truncateToSatoshi), + )), 1 day) + + { + val hc = HeuristicsConstants( + lockedFundsRisk = 0.0, + failureFees = RelayFees(1000 msat, 500), + hopFees = RelayFees(0 msat, 0), + useLogProbability = false, + usePastRelaysData = true, + ) + val Success(routes) = findRoute(g, start, b, DEFAULT_AMOUNT_MSAT, 100000000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(heuristics = hc), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(routes.distinct.length == 1) + val route :: Nil = routes + assert(route2Ids(route) == 0 :: 2 :: 3 :: 4 :: Nil) + + } + + { + val hc = HeuristicsConstants( + lockedFundsRisk = 0.0, + failureFees = RelayFees(10000 msat, 1000), + hopFees = RelayFees(0 msat, 0), + useLogProbability = true, + usePastRelaysData = true, + ) + val Success(routes) = findRoute(g, start, b, DEFAULT_AMOUNT_MSAT, 100000000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(heuristics = hc), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(routes.distinct.length == 1) + val route :: Nil = routes + assert(route2Ids(route) == 0 :: 2 :: 3 :: 4 :: Nil) + } + } + + test("no path that can get our funds stuck for too long") { + // S ---> A ---> B + // | ^ + // v | + // C ---> D + val start = randomKey().publicKey + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(0L, start, a, 0 msat, 0), + makeEdge(1L, a, b, 1000 msat, 1000, cltvDelta = CltvExpiryDelta(1000)), + makeEdge(2L, a, c, 350 msat, 350, cltvDelta = CltvExpiryDelta(10)), + makeEdge(3L, c, d, 350 msat, 350, cltvDelta = CltvExpiryDelta(10)), + makeEdge(4L, d, b, 350 msat, 350, cltvDelta = CltvExpiryDelta(10)), + )), 1 day) + + val hc = HeuristicsConstants( + lockedFundsRisk = 1e-7, + failureFees = RelayFees(0 msat, 0), + hopFees = RelayFees(0 msat, 0), + useLogProbability = true, + usePastRelaysData = true, + ) + val Success(routes) = findRoute(g, start, b, DEFAULT_AMOUNT_MSAT, 100000000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(heuristics = hc), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(routes.distinct.length == 1) + val route :: Nil = routes + assert(route2Ids(route) == 0 :: 2 :: 3 :: 4 :: Nil) + } + + test("edge too small to relay payment is ignored") { + // A ===> B ===> C <--- D + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, 100 msat, 100), + makeEdge(2L, b, c, 100 msat, 100), + makeEdge(3L, d, c, 100 msat, 100, capacity = 1000 sat), + )), 1 day) + + val hc = HeuristicsConstants( + lockedFundsRisk = 1e-7, + failureFees = RelayFees(0 msat, 0), + hopFees = RelayFees(0 msat, 0), + useLogProbability = true, + usePastRelaysData = true, + ) + val Success(routes) = findRoute(g, a, c, DEFAULT_AMOUNT_MSAT, 100000000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(heuristics = hc), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(routes.distinct.length == 1) + val route :: Nil = routes + assert(route2Ids(route) == 1 :: 2 :: Nil) + } + + test("use direct channel when available") { + // A ===> B ===> C + // \___________/ + val recentChannelId = ShortChannelId.fromCoordinates("399990x1x2").success.value.toLong + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, 1 msat, 1, capacity = 100_000_000 sat), + makeEdge(2L, b, c, 1 msat, 1, capacity = 100_000_000 sat), + makeEdge(recentChannelId, a, c, 1000 msat, 100), + )), 1 day) + + val wr = HeuristicsConstants( + lockedFundsRisk = 0, + failureFees = RelayFees(100 msat, 100), + hopFees = RelayFees(500 msat, 200), + useLogProbability = false, + usePastRelaysData = false, + ) + val Success(routes) = findRoute(g, a, c, DEFAULT_AMOUNT_MSAT, 100_000_000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(heuristics = wr), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(routes.distinct.length == 1) + val route :: Nil = routes + assert(route2Ids(route) == recentChannelId :: Nil) + } + + test("trampoline relay with direct channel to target") { + val amount = 100_000_000 msat + val g = GraphWithBalanceEstimates(DirectedGraph(List(makeEdge(1L, a, b, 1000 msat, 1000, capacity = 100_000_000 sat))), 1 day) + + { + val routeParams = DEFAULT_ROUTE_PARAMS.copy(includeLocalChannelCost = true, boundaries = SearchBoundaries(100_999 msat, 0.0, 6, CltvExpiryDelta(576))) + assert(findMultiPartRoute(g, a, b, amount, 100_999 msat, Set.empty, Set.empty, Set.empty, Nil, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) == Failure(RouteNotFound)) + } + { + val routeParams = DEFAULT_ROUTE_PARAMS.copy(includeLocalChannelCost = true, boundaries = SearchBoundaries(101_000 msat, 0.0, 6, CltvExpiryDelta(576))) + assert(findMultiPartRoute(g, a, b, amount, 101_000 msat, Set.empty, Set.empty, Set.empty, Nil, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true).isSuccess) + } + } + + test("small local edge with liquidity is better than big remote edge") { + // A == B == C -- D + // \_______/ + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, 100 msat, 100, minHtlc = 1000 msat, capacity = 100000000 sat, balance_opt = Some(10000000 msat)), + makeEdge(2L, b, c, 100 msat, 100, minHtlc = 1000 msat, capacity = 100000000 sat), + makeEdge(3L, a, c, 100 msat, 100, minHtlc = 1000 msat, capacity = 100 sat, balance_opt = Some(100000 msat)), + makeEdge(4L, c, d, 100 msat, 100, minHtlc = 1000 msat, capacity = 100000000 sat), + )), 1 day) + + val wr = HeuristicsConstants( + lockedFundsRisk = 0, + failureFees = RelayFees(1000 msat, 1000), + hopFees = RelayFees(0 msat, 0), + useLogProbability = false, + usePastRelaysData = false, + ) + val Success(routes) = findRoute(g, a, d, 50000 msat, 100000000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(heuristics = wr, includeLocalChannelCost = true), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val route :: Nil = routes + assert(route2Ids(route) == 3 :: 4 :: Nil) + } + + test("take past attempts into account") { + // C + // / \ + // A -- B E + // \ / + // D + val g = GraphWithBalanceEstimates(DirectedGraph(List( + makeEdge(1L, a, b, 100 msat, 100, minHtlc = 1000 msat, capacity = 100000000 sat), + makeEdge(2L, b, c, 100 msat, 100, minHtlc = 1000 msat, capacity = 100000000 sat), + makeEdge(3L, c, e, 100 msat, 100, minHtlc = 1000 msat, capacity = 100000000 sat), + makeEdge(4L, b, d, 1000 msat, 1000, minHtlc = 1000 msat, capacity = 100000 sat), + makeEdge(5L, d, e, 1000 msat, 1000, minHtlc = 1000 msat, capacity = 100000 sat), + )), 1 day) + + val amount = 50000 msat + + val hc = HeuristicsConstants( + lockedFundsRisk = 0, + failureFees = RelayFees(1000 msat, 1000), + hopFees = RelayFees(500 msat, 200), + useLogProbability = true, + usePastRelaysData = true + ) + val Success(route1 :: Nil) = findRoute(g, a, e, amount, 100000000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(heuristics = hc, includeLocalChannelCost = true), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(route2Ids(route1) == 1 :: 2 :: 3 :: Nil) + + val h = g.routeCouldRelay(route1.stopAt(c)).channelCouldNotSend(route1.hops.last, amount) + + val Success(route2 :: Nil) = findRoute(h, a, e, amount, 100000000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(heuristics = hc, includeLocalChannelCost = true), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + assert(route2Ids(route2) == 1 :: 4 :: 5 :: Nil) + } + +} + +object Blip18RouteCalculationSpec { + + val noopBoundaries = { _: PaymentPathWeight => true } + + 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: HeuristicsConstants = HeuristicsConstants(0, RelayFees(0 msat, 0), RelayFees(0 msat, 0), useLogProbability = false, usePastRelaysData = false) + val DEFAULT_ROUTE_PARAMS = PathFindingConf( + randomize = false, + boundaries = SearchBoundaries(21000 msat, 0.03, 6, CltvExpiryDelta(2016)), + NO_WEIGHT_RATIOS, + MultiPartParams(1000 msat, 10, FullCapacity), + experimentName = "my-test-experiment", + experimentPercentage = 100).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.orElse(Some(capacity.toMilliSatoshi)), 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 + ) + } + + def hops2Ids(hops: Seq[ChannelHop]): Seq[Long] = hops.map(hop => hop.shortChannelId.toLong) + + def route2Ids(route: Route): Seq[Long] = hops2Ids(route.hops) + + def routes2Ids(routes: Seq[Route]): Set[Seq[Long]] = routes.map(route2Ids).toSet + + def route2Edges(route: Route): Seq[GraphEdge] = route.hops.map(hop => GraphEdge(ChannelDesc(hop.shortChannelId, hop.nodeId, hop.nextNodeId), hop.params, 1000000 sat, None)) + + def route2Nodes(route: Route): Seq[(PublicKey, PublicKey)] = route.hops.map(hop => (hop.nodeId, hop.nextNodeId)) + + def route2NodeIds(route: Route): Seq[PublicKey] = route.hops.head.nodeId +: route.hops.map(_.nextNodeId) + + def checkIgnoredChannels(routes: Seq[Route], shortChannelIds: Long*): Unit = { + shortChannelIds.foreach(shortChannelId => routes.foreach(route => { + assert(route.hops.forall(_.shortChannelId.toLong != shortChannelId), route) + })) + } + + def checkRouteAmounts(routes: Seq[Route], totalAmount: MilliSatoshi, maxFee: MilliSatoshi): Unit = { + assert(routes.map(_.amount).sum == totalAmount, routes) + assert(routes.map(_.channelFee(false)).sum <= maxFee, routes) } } 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 03708532af..52ad96d8c0 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 @@ -125,7 +125,7 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution { )), 1 day) val Success(route :: Nil) = findRoute(graph, a, d, amount, maxFee = 7 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000)) - val weightedPath = Graph.pathWeight(graph.balances, a, route2Edges(route), amount, BlockHeight(0), NO_WEIGHT_RATIOS, includeLocalChannelCost = false) + val weightedPath = Graph.pathWeight(graph.balances, a, route2Edges(route), amount, BlockHeight(0), NO_WEIGHT_RATIOS, includeLocalChannelCost = false, graph.graph, enableInboundFees = false) assert(route2Ids(route) == 4 :: 5 :: 6 :: Nil) assert(weightedPath.length == 3) assert(weightedPath.amount == expectedCost) @@ -787,7 +787,7 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution { for (_ <- 0 to 10) { val Success(routes) = findRoute(g, a, d, DEFAULT_AMOUNT_MSAT, strictFee, numRoutes = 3, routeParams = strictFeeParams, currentBlockHeight = BlockHeight(400000)) assert(routes.length == 2, routes) - val weightedPath = Graph.pathWeight(g.balances, a, route2Edges(routes.head), DEFAULT_AMOUNT_MSAT, BlockHeight(400000), NO_WEIGHT_RATIOS, includeLocalChannelCost = false) + val weightedPath = Graph.pathWeight(g.balances, a, route2Edges(routes.head), DEFAULT_AMOUNT_MSAT, BlockHeight(400000), NO_WEIGHT_RATIOS, includeLocalChannelCost = false, g.graph, enableInboundFees = false) val totalFees = weightedPath.amount - DEFAULT_AMOUNT_MSAT // over the three routes we could only get the 2 cheapest because the third is too expensive (over 7 msat of fees) assert(totalFees == 5.msat || totalFees == 6.msat) diff --git a/eclair-node/src/test/resources/api/findroute-full b/eclair-node/src/test/resources/api/findroute-full index e1b2229777..4ab5fc7f6f 100644 --- a/eclair-node/src/test/resources/api/findroute-full +++ b/eclair-node/src/test/resources/api/findroute-full @@ -1 +1 @@ -{"routes":[{"amount":456,"hops":[{"nodeId":"03007e67dc5a8fd2b2ef21cb310ab6359ddb51f3f86a8b79b8b1e23bc3a6ea150a","nextNodeId":"026105f6cb4862810be989385d16f04b0f748f6f2a14040338b1a534d45b4be1c1","source":{"type":"announcement","channelUpdate":{"signature":"92cf3f12e161391986eb2cd7106ddab41a23c734f8f1ed120fb64f4b91f98f690ecf930388e62965f8aefbf1adafcd25a572669a125396dcfb83615208754679","chainHash":"024b7b3626554c44dcc2454ee3812458bfa68d9fced466edfab470844cb7ffe2","shortChannelId":"1x2x3","timestamp":{"iso":"1970-01-01T00:00:00Z","unix":0},"messageFlags":{"dontForward":false},"channelFlags":{"isEnabled":true,"isNode1":true},"cltvExpiryDelta":0,"htlcMinimumMsat":1,"feeBaseMsat":1,"feeProportionalMillionths":1,"htlcMaximumMsat":20000000,"tlvStream":{}}}},{"nodeId":"026105f6cb4862810be989385d16f04b0f748f6f2a14040338b1a534d45b4be1c1","nextNodeId":"038cfa2b5857843ee90cff91b06f692c0d8fe201921ee6387aee901d64f43699f0","source":{"type":"announcement","channelUpdate":{"signature":"92cf3f12e161391986eb2cd7106ddab41a23c734f8f1ed120fb64f4b91f98f690ecf930388e62965f8aefbf1adafcd25a572669a125396dcfb83615208754679","chainHash":"024b7b3626554c44dcc2454ee3812458bfa68d9fced466edfab470844cb7ffe2","shortChannelId":"1x2x4","timestamp":{"iso":"1970-01-01T00:00:00Z","unix":0},"messageFlags":{"dontForward":false},"channelFlags":{"isEnabled":true,"isNode1":true},"cltvExpiryDelta":0,"htlcMinimumMsat":1,"feeBaseMsat":1,"feeProportionalMillionths":1,"htlcMaximumMsat":20000000,"tlvStream":{}}}},{"nodeId":"038cfa2b5857843ee90cff91b06f692c0d8fe201921ee6387aee901d64f43699f0","nextNodeId":"02be60276e294c6921240daae33a361d214d02578656df0e74c61a09c3196e51df","source":{"type":"announcement","channelUpdate":{"signature":"92cf3f12e161391986eb2cd7106ddab41a23c734f8f1ed120fb64f4b91f98f690ecf930388e62965f8aefbf1adafcd25a572669a125396dcfb83615208754679","chainHash":"024b7b3626554c44dcc2454ee3812458bfa68d9fced466edfab470844cb7ffe2","shortChannelId":"1x2x5","timestamp":{"iso":"1970-01-01T00:00:00Z","unix":0},"messageFlags":{"dontForward":false},"channelFlags":{"isEnabled":true,"isNode1":true},"cltvExpiryDelta":0,"htlcMinimumMsat":1,"feeBaseMsat":1,"feeProportionalMillionths":1,"htlcMaximumMsat":20000000,"tlvStream":{}}}}]}]} \ No newline at end of file +{"routes":[{"amount":456,"hops":[{"nodeId":"03007e67dc5a8fd2b2ef21cb310ab6359ddb51f3f86a8b79b8b1e23bc3a6ea150a","nextNodeId":"026105f6cb4862810be989385d16f04b0f748f6f2a14040338b1a534d45b4be1c1","source":{"type":"announcement","channelUpdate":{"signature":"92cf3f12e161391986eb2cd7106ddab41a23c734f8f1ed120fb64f4b91f98f690ecf930388e62965f8aefbf1adafcd25a572669a125396dcfb83615208754679","chainHash":"024b7b3626554c44dcc2454ee3812458bfa68d9fced466edfab470844cb7ffe2","shortChannelId":"1x2x3","timestamp":{"iso":"1970-01-01T00:00:00Z","unix":0},"messageFlags":{"dontForward":false},"channelFlags":{"isEnabled":true,"isNode1":true},"cltvExpiryDelta":0,"htlcMinimumMsat":1,"feeBaseMsat":1,"feeProportionalMillionths":1,"htlcMaximumMsat":20000000,"tlvStream":{}}}},{"nodeId":"026105f6cb4862810be989385d16f04b0f748f6f2a14040338b1a534d45b4be1c1","nextNodeId":"038cfa2b5857843ee90cff91b06f692c0d8fe201921ee6387aee901d64f43699f0","source":{"type":"announcement","channelUpdate":{"signature":"92cf3f12e161391986eb2cd7106ddab41a23c734f8f1ed120fb64f4b91f98f690ecf930388e62965f8aefbf1adafcd25a572669a125396dcfb83615208754679","chainHash":"024b7b3626554c44dcc2454ee3812458bfa68d9fced466edfab470844cb7ffe2","shortChannelId":"1x2x4","timestamp":{"iso":"1970-01-01T00:00:00Z","unix":0},"messageFlags":{"dontForward":false},"channelFlags":{"isEnabled":true,"isNode1":true},"cltvExpiryDelta":0,"htlcMinimumMsat":1,"feeBaseMsat":1,"feeProportionalMillionths":1,"htlcMaximumMsat":20000000,"tlvStream":{}}}},{"nodeId":"038cfa2b5857843ee90cff91b06f692c0d8fe201921ee6387aee901d64f43699f0","nextNodeId":"02be60276e294c6921240daae33a361d214d02578656df0e74c61a09c3196e51df","source":{"type":"announcement","channelUpdate":{"signature":"92cf3f12e161391986eb2cd7106ddab41a23c734f8f1ed120fb64f4b91f98f690ecf930388e62965f8aefbf1adafcd25a572669a125396dcfb83615208754679","chainHash":"024b7b3626554c44dcc2454ee3812458bfa68d9fced466edfab470844cb7ffe2","shortChannelId":"1x2x5","timestamp":{"iso":"1970-01-01T00:00:00Z","unix":0},"messageFlags":{"dontForward":false},"channelFlags":{"isEnabled":true,"isNode1":true},"cltvExpiryDelta":0,"htlcMinimumMsat":1,"feeBaseMsat":1,"feeProportionalMillionths":1,"htlcMaximumMsat":20000000,"tlvStream":{}}}}],"fee":2}]} \ No newline at end of file From 1679f3a1c88f5317a869f588f6b864a5a2c3074d Mon Sep 17 00:00:00 2001 From: rorp Date: Sat, 4 Jul 2026 12:38:13 -0700 Subject: [PATCH 05/11] Peroformance fixes --- docs/release-notes/eclair-vnext.md | 12 +- .../scala/fr/acinq/eclair/db/Databases.scala | 18 ++- .../acinq/eclair/db/pg/PgInboundFeesDb.scala | 6 +- .../db/sqlite/SqliteInboundFeesDb.scala | 6 +- .../main/scala/fr/acinq/eclair/package.scala | 24 ++-- .../eclair/payment/relay/ChannelRelay.scala | 104 +++++++----------- .../eclair/payment/relay/ChannelRelayer.scala | 8 +- .../scala/fr/acinq/eclair/router/Graph.scala | 67 +++++------ .../wire/protocol/LightningMessageTypes.scala | 2 +- .../db/SqliteFileBackupHandlerSpec.scala | 16 ++- .../payment/relay/ChannelRelayerSpec.scala | 63 +++++++++-- .../router/Blip18RouteCalculationSpec.scala | 30 +++++ 12 files changed, 217 insertions(+), 139 deletions(-) diff --git a/docs/release-notes/eclair-vnext.md b/docs/release-notes/eclair-vnext.md index f270f90792..3f11da7e61 100644 --- a/docs/release-notes/eclair-vnext.md +++ b/docs/release-notes/eclair-vnext.md @@ -14,10 +14,10 @@ more flexible fee policies and incentivizing desired incoming traffic. #### Configuration -| Configuration Parameter | Default Value | Description | -|----------------------------------------------------------------------------------|---------------|----------------------------------------------------------------------------------------------------------------------------------------------------------| -| `eclair.router.path-finding.default.blip18-inbound-fees` | `false` | enables support for bLIP-18 inbound routing fees | -| `eclair.router.path-finding.default.exclude-channels-with-positive-inbound-fees` | `false` | enables exclusion of channels with positive inbound fees from path finding, helping to prevent `FeeInsufficient` errors and ensure more reliable routing | +| Configuration Parameter | Default Value | Description | +|--------------------------------------------------------------------------|---------------|----------------------------------------------------------------------------------------------------------------------------------------------------------| +| `eclair.router.path-finding.blip18-inbound-fees` | `false` | enables support for bLIP-18 inbound routing fees | +| `eclair.router.path-finding.exclude-channels-with-positive-inbound-fees` | `false` | enables exclusion of channels with positive inbound fees from path finding, helping to prevent `FeeInsufficient` errors and ensure more reliable routing | The routing logic considers inbound fees during route selection if enabled. New logic is added to exclude channels with positive inbound fees from route finding when configured. The relay and route calculation logic now computes total fees @@ -26,7 +26,9 @@ as the sum of the regular (outbound) and inbound fees when applicable. The wire protocol is updated to include the new TLV (0x55555) type for bLIP-18 inbound fees in ChannelUpdate messages. Code that (de)serializes channel updates now handles these new fields. -New database tables and migration updates for storing inbound fee information per peer. +Inbound fees are stored per peer in a new database: a separate `inboundfees.sqlite` file when using sqlite, or a new +`inboundfees` schema when using postgres. When using sqlite, the built-in backup mechanism maintains a snapshot of it +named `inboundfees.sqlite.bak`, next to `eclair.sqlite.bak`: if you set inbound fees, include that file in your backups. ### API changes diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/Databases.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/Databases.scala index 82f5676502..1127bec941 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/Databases.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/Databases.scala @@ -71,9 +71,21 @@ object Databases extends Logging { pendingCommands: SqlitePendingCommandsDb, inboundFees: SqliteInboundFeesDb, private val backupConnection: Connection) extends Databases with FileBackup { - override def backup(backupFile: File): Unit = SqliteUtils.using(backupConnection.createStatement()) { - statement => { - statement.executeUpdate(s"backup to ${backupFile.getAbsolutePath}") + override def backup(backupFile: File): Unit = { + // Inbound fees are stored in a separate database file, which we back up next to the main backup file. Our caller + // only handles the temporary file and atomic move for the main backup file, so we do it ourselves here. + val inboundFeesBackupFile = new File(backupFile.getAbsoluteFile.getParentFile, "inboundfees.sqlite.bak") + val inboundFeesTmpFile = new File(inboundFeesBackupFile.getAbsolutePath.concat(".tmp")) + SqliteUtils.using(inboundFees.sqlite.createStatement()) { + statement => { + statement.executeUpdate(s"backup to ${inboundFeesTmpFile.getAbsolutePath}") + } + } + Files.move(inboundFeesTmpFile.toPath, inboundFeesBackupFile.toPath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE) + SqliteUtils.using(backupConnection.createStatement()) { + statement => { + statement.executeUpdate(s"backup to ${backupFile.getAbsolutePath}") + } } } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgInboundFeesDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgInboundFeesDb.scala index bcbef5111f..4dceedd8af 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgInboundFeesDb.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgInboundFeesDb.scala @@ -6,7 +6,6 @@ import fr.acinq.eclair.db.InboundFeesDb import fr.acinq.eclair.db.Monitoring.Metrics.withMetrics import fr.acinq.eclair.db.Monitoring.Tags.DbBackends import fr.acinq.eclair.db.pg.PgUtils.PgLock -import fr.acinq.eclair.db.pg.PgUtils.PgLock.NoLock.withLock import fr.acinq.eclair.payment.relay.Relayer.InboundFees import grizzled.slf4j.Logging @@ -22,6 +21,7 @@ class PgInboundFeesDb(implicit ds: DataSource, lock: PgLock) extends InboundFees import PgUtils._ import ExtendedResultSet._ import PgInboundFeesDb._ + import lock._ inTransaction { pg => using(pg.createStatement()) { statement => @@ -36,7 +36,7 @@ class PgInboundFeesDb(implicit ds: DataSource, lock: PgLock) extends InboundFees } } - override def addOrUpdateInboundFees(nodeId: Crypto.PublicKey, fees: InboundFees): Unit = withMetrics("peers/add-or-update-relay-fees", DbBackends.Postgres) { + override def addOrUpdateInboundFees(nodeId: Crypto.PublicKey, fees: InboundFees): Unit = withMetrics("inbound-fees/add-or-update-inbound-fees", DbBackends.Postgres) { withLock { pg => using(pg.prepareStatement( """ @@ -53,7 +53,7 @@ class PgInboundFeesDb(implicit ds: DataSource, lock: PgLock) extends InboundFees } } - override def getInboundFees(nodeId: Crypto.PublicKey): Option[InboundFees] = withMetrics("peers/get-relay-fees", DbBackends.Postgres) { + override def getInboundFees(nodeId: Crypto.PublicKey): Option[InboundFees] = withMetrics("inbound-fees/get-inbound-fees", DbBackends.Postgres) { withLock { pg => using(pg.prepareStatement("SELECT fee_base_msat, fee_proportional_millionths FROM inboundfees.inbound_fees WHERE node_id=?")) { statement => statement.setString(1, nodeId.value.toHex) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteInboundFeesDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteInboundFeesDb.scala index 90c12e8c75..d1bb362192 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteInboundFeesDb.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteInboundFeesDb.scala @@ -3,6 +3,8 @@ package fr.acinq.eclair.db.sqlite import fr.acinq.bitcoin.scalacompat.Crypto import fr.acinq.eclair.MilliSatoshi import fr.acinq.eclair.db.InboundFeesDb +import fr.acinq.eclair.db.Monitoring.Metrics.withMetrics +import fr.acinq.eclair.db.Monitoring.Tags.DbBackends import fr.acinq.eclair.db.sqlite.SqliteUtils.{getVersion, setVersion, using} import fr.acinq.eclair.payment.relay.Relayer import fr.acinq.eclair.payment.relay.Relayer.{InboundFees, RelayFees} @@ -30,7 +32,7 @@ class SqliteInboundFeesDb(val sqlite: Connection) extends InboundFeesDb with Log setVersion(statement, DB_NAME, CURRENT_VERSION) } - override def addOrUpdateInboundFees(nodeId: Crypto.PublicKey, fees: Relayer.InboundFees): Unit = { + override def addOrUpdateInboundFees(nodeId: Crypto.PublicKey, fees: Relayer.InboundFees): Unit = withMetrics("inbound-fees/add-or-update-inbound-fees", DbBackends.Sqlite) { using(sqlite.prepareStatement("UPDATE inbound_fees SET fee_base_msat=?, fee_proportional_millionths=? WHERE node_id=?")) { update => update.setLong(1, fees.feeBase.toLong) update.setLong(2, fees.feeProportionalMillionths) @@ -46,7 +48,7 @@ class SqliteInboundFeesDb(val sqlite: Connection) extends InboundFeesDb with Log } } - override def getInboundFees(nodeId: Crypto.PublicKey): Option[Relayer.InboundFees] = { + override def getInboundFees(nodeId: Crypto.PublicKey): Option[Relayer.InboundFees] = withMetrics("inbound-fees/get-inbound-fees", DbBackends.Sqlite) { using(sqlite.prepareStatement("SELECT fee_base_msat, fee_proportional_millionths FROM inbound_fees WHERE node_id=?")) { statement => statement.setBytes(1, nodeId.value.toArray) statement.executeQuery() diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/package.scala b/eclair-core/src/main/scala/fr/acinq/eclair/package.scala index ac41e02ffe..17ed5a94e5 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/package.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/package.scala @@ -71,19 +71,21 @@ package object eclair { def nodeFee(relayFees: RelayFees, paymentAmount: MilliSatoshi): MilliSatoshi = nodeFee(relayFees.feeBase, relayFees.feeProportionalMillionths, paymentAmount) - def totalFee(amount: MilliSatoshi, baseFee: MilliSatoshi, proportionalFee: Long, inboundBaseFee_opt: Option[MilliSatoshi], inboundProportionalFee_opt: Option[Long]): MilliSatoshi = { - val outFee = nodeFee(baseFee, proportionalFee, amount) - val inFee = (for { - inboundBaseFee <- inboundBaseFee_opt - inboundProportionalFee <- inboundProportionalFee_opt - } yield nodeFee(inboundBaseFee, inboundProportionalFee, amount + outFee)).getOrElse(0 msat) - val totalFee = outFee + inFee - if (totalFee.toLong < 0) 0 msat else totalFee + /** + * @return the fee that a node should be paid to forward an HTLC of 'amount' millisatoshis, including its bLIP-18 + * inbound fees (if any). The inbound fee is computed on the amount entering the node (amount to forward + + * outbound fee); an inbound discount larger than the outbound fee doesn't make the total fee negative: + * nodes don't pay to relay payments. + */ + def totalFee(amount: MilliSatoshi, relayFees: RelayFees, inboundFees_opt: Option[InboundFees]): MilliSatoshi = inboundFees_opt match { + case Some(inboundFees) => + val outFee = nodeFee(relayFees, amount) + val inFee = nodeFee(inboundFees.feeBase, inboundFees.feeProportionalMillionths, amount + outFee) + val total = outFee + inFee + if (total.toLong < 0) 0 msat else total + case None => nodeFee(relayFees, amount) } - def totalFee(amount: MilliSatoshi, relayFees: RelayFees, inboundFees_opt: Option[InboundFees]): MilliSatoshi = - totalFee(amount, relayFees.feeBase, relayFees.feeProportionalMillionths, inboundFees_opt.map(_.feeBase), inboundFees_opt.map(_.feeProportionalMillionths)) - /** * @param baseFee fixed fee * @param proportionalFee proportional fee (millionths) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelay.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelay.scala index d215f303da..600eb86075 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelay.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelay.scala @@ -29,7 +29,7 @@ import fr.acinq.eclair.db.PendingCommandsDb import fr.acinq.eclair.io.Peer.ProposeOnTheFlyFundingResponse import fr.acinq.eclair.io.{Peer, PeerReadyNotifier} import fr.acinq.eclair.payment.Monitoring.{Metrics, Tags} -import fr.acinq.eclair.payment.relay.Relayer.{OutgoingChannel, OutgoingChannelParams} +import fr.acinq.eclair.payment.relay.Relayer.{InboundFees, OutgoingChannel, OutgoingChannelParams} import fr.acinq.eclair.payment.{ChannelPaymentRelayed, IncomingPaymentPacket, PaymentEvent, PaymentNotRelayed} import fr.acinq.eclair.reputation.Reputation import fr.acinq.eclair.reputation.ReputationRecorder.GetConfidence @@ -37,7 +37,6 @@ import fr.acinq.eclair.wire.protocol.FailureMessageCodecs.createBadOnionFailure import fr.acinq.eclair.wire.protocol.PaymentOnion.IntermediatePayload import fr.acinq.eclair.wire.protocol._ import fr.acinq.eclair.{EncodedNodeId, Features, InitFeature, Logs, NodeParams, TimestampMilli, TimestampSecond, channel, totalFee} -import fr.acinq.eclair.channel.{Command => ChannelCommand} import java.util.UUID import java.util.concurrent.TimeUnit @@ -51,10 +50,9 @@ object ChannelRelay { private case object DoRelay extends Command private case class WrappedPeerReadyResult(result: PeerReadyNotifier.Result) extends Command private case class WrappedReputationScore(score: Reputation.Score) extends Command - private case class WrappedForwardFailure(failure: Register.ForwardFailure[ChannelCommand]) extends Command + private case class WrappedForwardFailure(failure: Register.ForwardFailure[CMD_ADD_HTLC]) extends Command private case class WrappedAddResponse(res: CommandResponse[CMD_ADD_HTLC]) extends Command private case class WrappedOnTheFlyFundingResponse(result: Peer.ProposeOnTheFlyFundingResponse) extends Command - private case class WrappedChannelInfo(result: RES_GET_CHANNEL_INFO) extends Command // @formatter:on // @formatter:off @@ -71,7 +69,8 @@ object ChannelRelay { originNode: PublicKey, relayId: UUID, r: IncomingPaymentPacket.ChannelRelayPacket, - incomingChannelOccupancy: Double): Behavior[Command] = + incomingChannelOccupancy: Double, + inboundFees_opt: Option[InboundFees]): Behavior[Command] = Behaviors.setup { context => Behaviors.withMdc(Logs.mdc( category_opt = Some(Logs.LogCategory.PAYMENT), @@ -98,7 +97,7 @@ object ChannelRelay { } Behaviors.receiveMessagePartial { case WrappedReputationScore(score) => - new ChannelRelay(nodeParams, register, channels, r, upstream, score, context).start() + new ChannelRelay(nodeParams, register, channels, r, upstream, score, inboundFees_opt, context).start() } } } @@ -145,15 +144,15 @@ class ChannelRelay private(nodeParams: NodeParams, r: IncomingPaymentPacket.ChannelRelayPacket, upstream: Upstream.Hot.Channel, reputationScore: Reputation.Score, + inboundFees_opt: Option[InboundFees], context: ActorContext[ChannelRelay.Command]) { import ChannelRelay._ - private val forwardFailureAdapter = context.messageAdapter[Register.ForwardFailure[ChannelCommand]](WrappedForwardFailure) + private val forwardFailureAdapter = context.messageAdapter[Register.ForwardFailure[CMD_ADD_HTLC]](WrappedForwardFailure) private val addResponseAdapter = context.messageAdapter[CommandResponse[CMD_ADD_HTLC]](WrappedAddResponse) private val forwardNodeIdFailureAdapter = context.messageAdapter[Register.ForwardNodeIdFailure[Peer.ProposeOnTheFlyFunding]](_ => WrappedOnTheFlyFundingResponse(Peer.ProposeOnTheFlyFundingResponse.NotAvailable("peer not found"))) private val onTheFlyFundingResponseAdapter = context.messageAdapter[Peer.ProposeOnTheFlyFundingResponse](WrappedOnTheFlyFundingResponse) - private val channelInfoAdapter = context.messageAdapter[RES_GET_CHANNEL_INFO](WrappedChannelInfo) private val nextPathKey_opt = r.payload match { case payload: IntermediatePayload.ChannelRelay.Blinded => Some(payload.nextPathKey) @@ -201,53 +200,30 @@ class ChannelRelay private(nodeParams: NodeParams, } } - def relay(remoteFeatures_opt: Option[Features[InitFeature]], previousFailures: Seq[PreviouslyTried], inboundChannelUpdate_opt: Option[Either[Unit, ChannelUpdate]] = None): Behavior[Command] = { + def relay(remoteFeatures_opt: Option[Features[InitFeature]], previousFailures: Seq[PreviouslyTried]): Behavior[Command] = { Behaviors.receiveMessagePartial { case DoRelay => - if (nodeParams.routerConf.blip18InboundFees && inboundChannelUpdate_opt.isEmpty) { - register ! Register.Forward(forwardFailureAdapter, r.add.channelId, CMD_GET_CHANNEL_INFO(channelInfoAdapter)) - waitForInboundChannelInfo(remoteFeatures_opt, previousFailures) - } else { - if (previousFailures.isEmpty) { - val nextNodeId_opt = channels.headOption.map(_._2.nextNodeId) - context.log.info("relaying htlc #{} from channelId={} to requestedShortChannelId={} nextNode={}", r.add.id, r.add.channelId, requestedShortChannelId_opt, nextNodeId_opt.getOrElse("")) - } - context.log.debug("attempting relay previousAttempts={}", previousFailures.size) - handleRelay(remoteFeatures_opt, previousFailures, inboundChannelUpdate_opt.flatMap(_.toOption)) match { - case RelayFailure(cmdFail) => - Metrics.recordPaymentRelayFailed(Tags.FailureType(cmdFail), Tags.RelayType.Channel) - context.log.info("rejecting htlc reason={}", cmdFail.reason) - channels.headOption.map(_._2.nextNodeId).foreach(nextNodeId => context.system.eventStream ! EventStream.Publish(PaymentNotRelayed(r.add.paymentHash, nextNodeId, fees = upstream.amountIn - r.amountToForward))) - safeSendAndStop(r.add.channelId, cmdFail) - case RelayNeedsFunding(nextNodeId, cmdFail) => - // Note that in the channel relay case, we don't have any outgoing onion shared secrets. - val cmd = Peer.ProposeOnTheFlyFunding(onTheFlyFundingResponseAdapter, r.amountToForward, r.add.paymentHash, r.outgoingCltv, r.nextPacket, Nil, nextPathKey_opt, upstream) - register ! Register.ForwardNodeId(forwardNodeIdFailureAdapter, nextNodeId, cmd) - waitForOnTheFlyFundingResponse(cmdFail) - case RelaySuccess(selectedChannelId, cmdAdd) => - context.log.info("forwarding htlc #{} from channelId={} to channelId={}", r.add.id, r.add.channelId, selectedChannelId) - register ! Register.Forward(forwardFailureAdapter, selectedChannelId, cmdAdd) - waitForAddResponse(selectedChannelId, remoteFeatures_opt, previousFailures) - } + if (previousFailures.isEmpty) { + val nextNodeId_opt = channels.headOption.map(_._2.nextNodeId) + context.log.info("relaying htlc #{} from channelId={} to requestedShortChannelId={} nextNode={}", r.add.id, r.add.channelId, requestedShortChannelId_opt, nextNodeId_opt.getOrElse("")) } - } - } - - private def waitForInboundChannelInfo(remoteFeatures_opt: Option[Features[InitFeature]], previousFailures: Seq[PreviouslyTried]): Behavior[Command] = { - Behaviors.receiveMessagePartial { - case WrappedChannelInfo(res) => - val inboundChannelUpdate_opt = res.data match { - case d: DATA_NORMAL => Some(Right(d.channelUpdate)) - case _ => - context.log.error("couldn't get channel info for channel {}: invalid channel state {}", res.channelId, res.state) - Some(Left(())) + context.log.debug("attempting relay previousAttempts={}", previousFailures.size) + handleRelay(remoteFeatures_opt, previousFailures) match { + case RelayFailure(cmdFail) => + Metrics.recordPaymentRelayFailed(Tags.FailureType(cmdFail), Tags.RelayType.Channel) + context.log.info("rejecting htlc reason={}", cmdFail.reason) + channels.headOption.map(_._2.nextNodeId).foreach(nextNodeId => context.system.eventStream ! EventStream.Publish(PaymentNotRelayed(r.add.paymentHash, nextNodeId, fees = upstream.amountIn - r.amountToForward))) + safeSendAndStop(r.add.channelId, cmdFail) + case RelayNeedsFunding(nextNodeId, cmdFail) => + // Note that in the channel relay case, we don't have any outgoing onion shared secrets. + val cmd = Peer.ProposeOnTheFlyFunding(onTheFlyFundingResponseAdapter, r.amountToForward, r.add.paymentHash, r.outgoingCltv, r.nextPacket, Nil, nextPathKey_opt, upstream) + register ! Register.ForwardNodeId(forwardNodeIdFailureAdapter, nextNodeId, cmd) + waitForOnTheFlyFundingResponse(cmdFail) + case RelaySuccess(selectedChannelId, cmdAdd) => + context.log.info("forwarding htlc #{} from channelId={} to channelId={}", r.add.id, r.add.channelId, selectedChannelId) + register ! Register.Forward(forwardFailureAdapter, selectedChannelId, cmdAdd) + waitForAddResponse(selectedChannelId, remoteFeatures_opt, previousFailures) } - context.self ! DoRelay - relay(remoteFeatures_opt, previousFailures, inboundChannelUpdate_opt) - case WrappedForwardFailure(failure) => - context.log.error("couldn't get channel info for {}", failure.fwd.channelId) - context.self ! DoRelay - relay(remoteFeatures_opt, previousFailures, Some(Left(()))) } } @@ -357,10 +333,10 @@ class ChannelRelay private(nodeParams: NodeParams, * - a CMD_FAIL_HTLC to be sent back upstream * - a CMD_ADD_HTLC to propagate downstream */ - private def handleRelay(remoteFeatures_opt: Option[Features[InitFeature]], previousFailures: Seq[PreviouslyTried], inboundChannelUpdate_opt: Option[ChannelUpdate]): RelayResult = { + private def handleRelay(remoteFeatures_opt: Option[Features[InitFeature]], previousFailures: Seq[PreviouslyTried]): RelayResult = { val alreadyTried = previousFailures.map(_.channelId) - selectPreferredChannel(alreadyTried, inboundChannelUpdate_opt) match { - case Some(outgoingChannel) => relayOrFail(outgoingChannel, inboundChannelUpdate_opt) + selectPreferredChannel(alreadyTried) match { + case Some(outgoingChannel) => relayOrFail(outgoingChannel) case None => // No more channels to try. val cmdFail = if (previousFailures.nonEmpty) { @@ -375,7 +351,7 @@ class ChannelRelay private(nodeParams: NodeParams, makeCmdFailHtlc(r.add.id, UnknownNextPeer()) } walletNodeId_opt match { - case Some(walletNodeId) if shouldAttemptOnTheFlyFunding(remoteFeatures_opt, previousFailures, inboundChannelUpdate_opt) => RelayNeedsFunding(walletNodeId, cmdFail) + case Some(walletNodeId) if shouldAttemptOnTheFlyFunding(remoteFeatures_opt, previousFailures) => RelayNeedsFunding(walletNodeId, cmdFail) case _ => RelayFailure(cmdFail) } } @@ -387,7 +363,7 @@ class ChannelRelay private(nodeParams: NodeParams, * * If no suitable channel is found we default to the originally requested channel. */ - private def selectPreferredChannel(alreadyTried: Seq[ByteVector32], inboundChannelUpdate_opt: Option[ChannelUpdate]): Option[OutgoingChannel] = { + private def selectPreferredChannel(alreadyTried: Seq[ByteVector32]): Option[OutgoingChannel] = { context.log.debug("selecting next channel with requestedShortChannelId={}", requestedShortChannelId_opt) // we filter out channels that we have already tried val candidateChannels: Map[ByteVector32, OutgoingChannel] = channels -- alreadyTried @@ -395,7 +371,7 @@ class ChannelRelay private(nodeParams: NodeParams, candidateChannels .values .map { channel => - val relayResult = relayOrFail(channel, inboundChannelUpdate_opt) + val relayResult = relayOrFail(channel) context.log.debug("candidate channel: channelId={} availableForSend={} capacity={} channelUpdate={} result={}", channel.channelId, channel.commitments.availableBalanceForSend, @@ -440,9 +416,9 @@ class ChannelRelay private(nodeParams: NodeParams, * channel, because some parameters don't match with our settings for that channel. In that case we directly fail the * htlc. */ - private def relayOrFail(outgoingChannel: OutgoingChannelParams, inboundChannelUpdate_opt: Option[ChannelUpdate]): RelayResult = { + private def relayOrFail(outgoingChannel: OutgoingChannelParams): RelayResult = { val update = outgoingChannel.channelUpdate - validateRelayParams(outgoingChannel, inboundChannelUpdate_opt) match { + validateRelayParams(outgoingChannel) match { case Some(fail) => RelayFailure(fail) case None if !update.channelFlags.isEnabled => @@ -453,15 +429,15 @@ class ChannelRelay private(nodeParams: NodeParams, } } - private def validateRelayParams(outgoingChannel: OutgoingChannelParams, inboundChannelUpdate_opt: Option[ChannelUpdate]): Option[CMD_FAIL_HTLC] = { + private def validateRelayParams(outgoingChannel: OutgoingChannelParams): Option[CMD_FAIL_HTLC] = { val update = outgoingChannel.channelUpdate // If our current channel update was recently created, we accept payments that used our previous channel update. val allowPreviousUpdate = TimestampSecond.now() - update.timestamp <= nodeParams.relayParams.enforcementDelay val prevUpdate_opt = if (allowPreviousUpdate) outgoingChannel.prevChannelUpdate else None val htlcMinimumOk = update.htlcMinimumMsat <= r.amountToForward || prevUpdate_opt.exists(_.htlcMinimumMsat <= r.amountToForward) val expiryDeltaOk = update.cltvExpiryDelta <= r.expiryDelta || prevUpdate_opt.exists(_.cltvExpiryDelta <= r.expiryDelta) - val feesOk = totalFee(r.amountToForward, update.relayFees, inboundChannelUpdate_opt.flatMap(_.blip18InboundFees_opt)) <= r.relayFeeMsat || - prevUpdate_opt.exists(u => totalFee(r.amountToForward, u.relayFees, inboundChannelUpdate_opt.flatMap(_.blip18InboundFees_opt)) <= r.relayFeeMsat) + val feesOk = totalFee(r.amountToForward, update.relayFees, inboundFees_opt) <= r.relayFeeMsat || + prevUpdate_opt.exists(u => totalFee(r.amountToForward, u.relayFees, inboundFees_opt) <= r.relayFeeMsat) if (!htlcMinimumOk) { Some(makeCmdFailHtlc(r.add.id, AmountBelowMinimum(r.amountToForward, Some(update)))) } else if (!expiryDeltaOk) { @@ -474,7 +450,7 @@ class ChannelRelay private(nodeParams: NodeParams, } /** If we fail to relay a payment, we may want to attempt on-the-fly funding. */ - private def shouldAttemptOnTheFlyFunding(remoteFeatures_opt: Option[Features[InitFeature]], previousFailures: Seq[PreviouslyTried], inboundChannelUpdate_opt: Option[ChannelUpdate]): Boolean = { + private def shouldAttemptOnTheFlyFunding(remoteFeatures_opt: Option[Features[InitFeature]], previousFailures: Seq[PreviouslyTried]): Boolean = { val featureOk = Features.canUseFeature(nodeParams.features.initFeatures(), remoteFeatures_opt.getOrElse(Features.empty), Features.OnTheFlyFunding) // If we have a channel with the next node, we only want to perform on-the-fly funding for liquidity issues. val liquidityIssue = previousFailures.forall { @@ -483,7 +459,7 @@ class ChannelRelay private(nodeParams: NodeParams, } // If we have a channel with the next peer, but we skipped it because the sender is using invalid relay parameters, // we don't want to perform on-the-fly funding: the sender should send a valid payment first. - val relayParamsOk = channels.values.forall(c => validateRelayParams(c, inboundChannelUpdate_opt).isEmpty) + val relayParamsOk = channels.values.forall(c => validateRelayParams(c).isEmpty) featureOk && liquidityIssue && relayParamsOk } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelayer.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelayer.scala index ed25d79b6f..8a19098b11 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelayer.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelayer.scala @@ -83,8 +83,14 @@ object ChannelRelayer { case Some(nextNodeId) => node2channels.get(nextNodeId).flatMap(channels.get).map(c => c.channelId -> c).toMap case None => Map.empty } + // bLIP-18 inbound fees advertised in our channel_update for the incoming channel apply to this relay. + val inboundFees_opt = if (nodeParams.routerConf.blip18InboundFees) { + channels.get(channelRelayPacket.add.channelId).flatMap(_.channelUpdate.blip18InboundFees_opt) + } else { + None + } context.log.debug(s"spawning a new handler with relayId=$relayId to nextNodeId={} with channels={}", nextNodeId_opt.getOrElse(""), nextChannels.keys.mkString(",")) - context.spawn(ChannelRelay.apply(nodeParams, register, reputationRecorder_opt, nextChannels, originNode, relayId, channelRelayPacket, incomingChannelOccupancy), name = relayId.toString) + context.spawn(ChannelRelay.apply(nodeParams, register, reputationRecorder_opt, nextChannels, originNode, relayId, channelRelayPacket, incomingChannelOccupancy, inboundFees_opt), name = relayId.toString) Behaviors.same case GetOutgoingChannels(replyTo, Relayer.GetOutgoingChannels(enabledOnly)) => 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 a6f9afb4e6..82afd5c8a6 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 @@ -49,8 +49,11 @@ object Graph { * @param successProbability estimate of the probability that the payment would succeed using this path * @param fees total fees of the path * @param weight cost multiplied by a factor based on heuristics (see [[PaymentWeightRatios]]). + * @param outboundFee outbound fee of the last edge added to the path (paths are computed backwards, so this is + * the fee of the node closest to the sender so far): it caps the inbound fee discount + * (bLIP-18) of the next edge we add. */ - case class PaymentPathWeight(amount: MilliSatoshi, length: Int, cltv: CltvExpiryDelta, successProbability: Double, fees: MilliSatoshi, virtualFees: MilliSatoshi, weight: Double, outboundFee: MilliSatoshi, feesForWeight: MilliSatoshi) extends PathWeight { + case class PaymentPathWeight(amount: MilliSatoshi, length: Int, cltv: CltvExpiryDelta, successProbability: Double, fees: MilliSatoshi, virtualFees: MilliSatoshi, weight: Double, outboundFee: MilliSatoshi) extends PathWeight { override def canUseEdge(edge: GraphEdge): Boolean = amount <= edge.capacity && edge.balance_opt.forall(amount <= _) && @@ -59,7 +62,7 @@ object Graph { } object PaymentPathWeight { - def apply(amount: MilliSatoshi): PaymentPathWeight = PaymentPathWeight(amount, 0, CltvExpiryDelta(0), 1.0, 0 msat, 0 msat, 0.0, 0 msat, 0 msat) + def apply(amount: MilliSatoshi): PaymentPathWeight = PaymentPathWeight(amount, 0, CltvExpiryDelta(0), 1.0, 0 msat, 0 msat, 0.0, 0 msat) } /** @@ -124,16 +127,18 @@ object Graph { } val totalSuccessProbability = prev.successProbability * successProbability val failureCost = nodeFee(failureFees, totalAmount) - val feeForWeight = math.max(0L, fee.toLong) - val totalFeesForWeight = prev.feesForWeight + fee + // The weight must never decrease when adding an edge, otherwise Dijkstra's assumption of non-negative edge + // weights is violated: settled nodes could be improved, corrupting the paths found. Inbound fee discounts + // (bLIP-18) can make an edge's fee negative, so we floor each edge's weight contribution at zero (like lnd + // does): a hop can at best be free, it can never make the path cheaper. val richWeight = if (useLogProbability) { val riskCost = totalAmount.toLong * edge.params.cltvExpiryDelta.toInt * lockedFundsRisk - val weight = prev.weight + feeForWeight + hopCost.toLong + riskCost - failureCost.toLong * math.log(successProbability) - PaymentPathWeight(totalAmount, prev.length + 1, totalCltv, totalSuccessProbability, totalFees, totalHopsCost, weight, edgeOutboundFee, totalFeesForWeight) + val edgeCost = math.max(0.0, fee.toLong + hopCost.toLong + riskCost - failureCost.toLong * math.log(successProbability)) + PaymentPathWeight(totalAmount, prev.length + 1, totalCltv, totalSuccessProbability, totalFees, totalHopsCost, prev.weight + edgeCost, edgeOutboundFee) } else { val totalRiskCost = totalAmount.toLong * totalCltv.toInt * lockedFundsRisk - val weight = totalFeesForWeight.toLong + totalHopsCost.toLong + totalRiskCost + failureCost.toLong / totalSuccessProbability - PaymentPathWeight(totalAmount, prev.length + 1, totalCltv, totalSuccessProbability, totalFees, totalHopsCost, weight, edgeOutboundFee, totalFeesForWeight) + val weight = math.max(prev.weight, totalFees.toLong + totalHopsCost.toLong + totalRiskCost + failureCost.toLong / totalSuccessProbability) + PaymentPathWeight(totalAmount, prev.length + 1, totalCltv, totalSuccessProbability, totalFees, totalHopsCost, weight, edgeOutboundFee) } if (edge.desc.a == sender) { // If this is a local channel it shouldn't add any weight. We always prefer local channels. @@ -355,37 +360,19 @@ object Graph { if (current.weight.canUseEdge(edge) && !ignoredEdges.contains(edge.desc) && !ignoredVertices.contains(neighbor) && - (!excludePositiveInboundFees || g.graph.getBackEdge(edge).flatMap(_.getChannelUpdate).flatMap(_.blip18InboundFees_opt).forall(i => i.feeBase.toLong <= 0 && i.feeProportionalMillionths <= 0)) && (neighbor == sourceNode || g.graph.getVertexFeatures(neighbor).areSupported(nodeFeatures))) { - // 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 inboundFees_opt = if (edge.desc.b == targetNode) None else getInboundFees(g.graph, edge) - val neighborWeight = wr.addEdgeWeight(sourceNode, edge, g.balances.get(edge), current.weight, currentBlockHeight, includeLocalChannelCost, inboundFees_opt, enableInboundFees) - if (boundaries(neighborWeight)) { - val previousNeighborWeight = bestWeights.get(neighbor) - // if this path between neighbor and the target has a shorter distance than previously known, we select it - if (previousNeighborWeight.forall(_.weight > neighborWeight.weight)) { - // Only check for cycles when neighbor is already settled (in visitedNodes). - // A settled node's bestEdges entry is final; if it chains back to current.key, - // setting bestEdges[neighbor]=edge would create a permanent cycle in path reconstruction. - // For unsettled neighbors the chain is still changing, so the check is too conservative. - val wouldCreateCycle = visitedNodes.contains(neighbor) && { - @tailrec - def reachesNeighbor(node: PublicKey): Boolean = { - if (node == targetNode) { - false - } else { - bestEdges.get(node) match { - case Some(e) if e.desc.b == neighbor => true - case Some(e) => reachesNeighbor(e.desc.b) - case None => false - } - } - } - - reachesNeighbor(current.key) - } - if (!wouldCreateCycle) { + // Inbound fees never apply to the last edge of the path: the recipient doesn't relay the payment, so it + // cannot charge relay fees, regardless of what its channel_update advertises. + val inboundFees_opt = if (enableInboundFees && edge.desc.b != targetNode) getInboundFees(g.graph, edge) else None + val excludeEdge = excludePositiveInboundFees && inboundFees_opt.exists(i => i.feeBase.toLong > 0 || i.feeProportionalMillionths > 0) + if (!excludeEdge) { + // 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 = wr.addEdgeWeight(sourceNode, edge, g.balances.get(edge), current.weight, currentBlockHeight, includeLocalChannelCost, inboundFees_opt, enableInboundFees) + if (boundaries(neighborWeight)) { + val previousNeighborWeight = bestWeights.get(neighbor) + // if this path between neighbor and the target has a shorter distance than previously known, we select it + if (previousNeighborWeight.forall(_.weight > neighborWeight.weight)) { // update the best edge for this vertex bestEdges.put(neighbor, edge) // add this updated node to the list for further exploration @@ -591,7 +578,9 @@ object Graph { if (path.isEmpty) return PaymentPathWeight(amount) val targetNode = path.last.desc.b path.foldRight(PaymentPathWeight(amount)) { (edge, prev) => - val inboundFees_opt = if (edge.desc.b == targetNode) None else getInboundFees(graph, edge) + // Inbound fees never apply to the last edge of the path: the recipient doesn't relay the payment, so it + // cannot charge relay fees, regardless of what its channel_update advertises. + val inboundFees_opt = if (enableInboundFees && edge.desc.b != targetNode) getInboundFees(graph, edge) else None wr.addEdgeWeight(sender, edge, balances.get(edge), prev, currentBlockHeight, includeLocalChannelCost, inboundFees_opt, enableInboundFees) } } 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 50732e58a1..e0ab9df310 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 @@ -776,7 +776,7 @@ case class ChannelUpdate(signature: ByteVector64, def relayFees: Relayer.RelayFees = Relayer.RelayFees(feeBase = feeBaseMsat, feeProportionalMillionths = feeProportionalMillionths) - def blip18InboundFees_opt: Option[Relayer.InboundFees] = + val 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/db/SqliteFileBackupHandlerSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/db/SqliteFileBackupHandlerSpec.scala index c73a42739b..61a83dc35d 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/db/SqliteFileBackupHandlerSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/db/SqliteFileBackupHandlerSpec.scala @@ -21,9 +21,10 @@ import akka.testkit.TestProbe import fr.acinq.eclair.channel.ChannelPersisted import fr.acinq.eclair.db.Databases.FileBackup import fr.acinq.eclair.db.FileBackupHandler.{BackupCompleted, BackupEvent} -import fr.acinq.eclair.db.sqlite.SqliteChannelsDb +import fr.acinq.eclair.db.sqlite.{SqliteChannelsDb, SqliteInboundFeesDb} +import fr.acinq.eclair.payment.relay.Relayer.InboundFees import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec -import fr.acinq.eclair.{TestConstants, TestDatabases, TestKitBaseClass, TestUtils, randomBytes32} +import fr.acinq.eclair.{MilliSatoshiLong, TestConstants, TestDatabases, TestKitBaseClass, TestUtils, randomBytes32, randomKey} import org.scalatest.funsuite.AnyFunSuiteLike import java.io.File @@ -37,9 +38,17 @@ class SqliteFileBackupHandlerSpec extends TestKitBaseClass with AnyFunSuiteLike val db = TestDatabases.inMemoryDb() val dest = new File(TestUtils.BUILD_DIRECTORY, s"backup-${UUID.randomUUID()}") dest.deleteOnExit() + // inbound fees are backed up to a separate file, always named inboundfees.sqlite.bak, next to the main backup file + val inboundFeesDest = new File(TestUtils.BUILD_DIRECTORY, "inboundfees.sqlite.bak") + inboundFeesDest.delete() + inboundFeesDest.deleteOnExit() val channel = ChannelCodecsSpec.normal db.channels.addOrUpdateChannel(channel) assert(db.channels.listLocalChannels() == Seq(channel)) + val nodeId = randomKey().publicKey + val inboundFees = InboundFees(1000 msat, 100) + db.inboundFees.addOrUpdateInboundFees(nodeId, inboundFees) + assert(db.inboundFees.getInboundFees(nodeId).contains(inboundFees)) val params = FileBackupHandler.FileBackupParams( interval = 10 seconds, @@ -60,5 +69,8 @@ class SqliteFileBackupHandlerSpec extends TestKitBaseClass with AnyFunSuiteLike val db1 = new SqliteChannelsDb(DriverManager.getConnection(s"jdbc:sqlite:$dest")) val check = db1.listLocalChannels() assert(check == Seq(channel)) + + val db2 = new SqliteInboundFeesDb(DriverManager.getConnection(s"jdbc:sqlite:$inboundFeesDest")) + assert(db2.getInboundFees(nodeId).contains(inboundFees)) } } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/ChannelRelayerSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/ChannelRelayerSpec.scala index 9a05ef06de..bcc07b90b8 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/ChannelRelayerSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/ChannelRelayerSpec.scala @@ -59,6 +59,7 @@ class ChannelRelayerSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("a val wakeUpTimeout = "wake_up_timeout" val onTheFlyFunding = "on_the_fly_funding" val ignoreReputation = "ignore_reputation" + val blip18InboundFees = "blip18_inbound_fees" case class FixtureParam(nodeParams: NodeParams, channelRelayer: typed.ActorRef[ChannelRelayer.Command], register: TestProbe[Any], reputationRecorder: TestProbe[ReputationRecorder.Command]) { def createWakeUpActors(): (TestProbe[PeerReadyManager.Register], TestProbe[Switchboard.GetPeerInfo]) = { @@ -86,6 +87,7 @@ class ChannelRelayerSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("a .modify(_.peerWakeUpConfig.enabled).setToIf(test.tags.contains(wakeUpEnabled))(true) .modify(_.peerWakeUpConfig.timeout).setToIf(test.tags.contains(wakeUpTimeout))(100 millis) .modify(_.features.activated).usingIf(test.tags.contains(onTheFlyFunding))(_ + (Features.OnTheFlyFunding -> FeatureSupport.Optional)) + .modify(_.routerConf.blip18InboundFees).setToIf(test.tags.contains(blip18InboundFees))(true) val register = TestProbe[Any]("register") val reputationRecorder = TestProbe[ReputationRecorder.Command]("reputation-recorder") val channelRelayer = testKit.spawn(ChannelRelayer.apply(nodeParams, register.ref.toClassic, if (test.tags.contains(ignoreReputation)) None else Some(reputationRecorder.ref))) @@ -538,17 +540,62 @@ class ChannelRelayerSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("a expectFwdFail(register, r.add.channelId, CMD_FAIL_HTLC(r.add.id, FailureReason.LocalFailure(FeeInsufficient(r.add.amountMsat, Some(u3.channelUpdate))), None, commit = true)) } - ignore("relay that would fail (fee insufficient) when inbound fees are set") { f => + test("fail to relay when fee is insufficient because of inbound fees", Tag(blip18InboundFees)) { f => import f._ + // The HTLC comes in through channel 2, whose channel_update advertises positive inbound fees. We give that channel + // a low balance so that it isn't a candidate outgoing channel. + val u_in = createLocalUpdate(channelId2, balance = 1_000_000 msat, inboundFees_opt = Some(InboundFees(10_000 msat, 100_000))) + val u_out = createLocalUpdate(channelId1) val payload = ChannelRelay.Standard(realScid1, outgoingAmount, outgoingExpiry, upgradeAccountability = false) - val r = createValidIncomingPacket(payload) - val u = createLocalUpdate(channelId1, inboundFees_opt = Some(InboundFees(10000 msat, 100000))) + val r = createValidIncomingPacket(payload, incomingChannelId = channelId2) - channelRelayer ! WrappedLocalChannelUpdate(u) - channelRelayer ! Relay(r, TestConstants.Alice.nodeParams.nodeId, 1.0) + channelRelayer ! WrappedLocalChannelUpdate(u_in) + channelRelayer ! WrappedLocalChannelUpdate(u_out) + channelRelayer ! Relay(r, TestConstants.Alice.nodeParams.nodeId, 0.1) + receiveConfidence(Reputation.Score.max(accountable = false)) - expectFwdFail(register, r.add.channelId, CMD_FAIL_HTLC(r.add.id, FailureReason.LocalFailure(FeeInsufficient(r.add.amountMsat, Some(u.channelUpdate))), None, commit = true)) + // The outbound fee alone (2 000 msat) is largely covered by what the sender paid (1 000 000 msat), but adding the + // inbound fee (1 010 200 msat) makes the relay unprofitable. + expectFwdFail(register, r.add.channelId, CMD_FAIL_HTLC(r.add.id, FailureReason.LocalFailure(FeeInsufficient(r.add.amountMsat, Some(u_out.channelUpdate))), None, commit = true)) + } + + test("relay with an inbound fee discount", Tag(blip18InboundFees)) { f => + import f._ + + // The HTLC comes in through channel 2, whose channel_update advertises an inbound fee discount. We give that + // channel a low balance so that it isn't a candidate outgoing channel. + val u_in = createLocalUpdate(channelId2, balance = 1_000_000 msat, inboundFees_opt = Some(InboundFees(-1_000 msat, -50))) + val u_out = createLocalUpdate(channelId1) + val payload = ChannelRelay.Standard(realScid1, outgoingAmount, outgoingExpiry, upgradeAccountability = false) + // The relay fee paid by the sender (1 000 msat) is below our outbound fee (2 000 msat), but the inbound discount + // (-1 500 msat) brings the total fee down to 500 msat, which makes the relay acceptable. + val r = createValidIncomingPacket(payload, amountIn = outgoingAmount + 1_000.msat, incomingChannelId = channelId2) + + channelRelayer ! WrappedLocalChannelUpdate(u_in) + channelRelayer ! WrappedLocalChannelUpdate(u_out) + channelRelayer ! Relay(r, TestConstants.Alice.nodeParams.nodeId, 0.1) + receiveConfidence(Reputation.Score.max(accountable = false)) + + expectFwdAdd(register, channelId1, outgoingAmount, outgoingExpiry, outAccountable = false) + } + + test("relay ignoring inbound fees when bLIP-18 is disabled") { f => + import f._ + + // The incoming channel advertises positive inbound fees, but bLIP-18 support is disabled: inbound fees must not + // be enforced on the relay. + val u_in = createLocalUpdate(channelId2, balance = 1_000_000 msat, inboundFees_opt = Some(InboundFees(10_000 msat, 100_000))) + val u_out = createLocalUpdate(channelId1) + val payload = ChannelRelay.Standard(realScid1, outgoingAmount, outgoingExpiry, upgradeAccountability = false) + val r = createValidIncomingPacket(payload, incomingChannelId = channelId2) + + channelRelayer ! WrappedLocalChannelUpdate(u_in) + channelRelayer ! WrappedLocalChannelUpdate(u_out) + channelRelayer ! Relay(r, TestConstants.Alice.nodeParams.nodeId, 0.1) + receiveConfidence(Reputation.Score.max(accountable = false)) + + expectFwdAdd(register, channelId1, outgoingAmount, outgoingExpiry, outAccountable = false) } @@ -924,13 +971,13 @@ object ChannelRelayerSpec { ChannelRelay.Blinded(tlvs, PaymentRelayData(blindedTlvs), randomKey().publicKey) } - def createValidIncomingPacket(payload: IntermediatePayload.ChannelRelay, amountIn: MilliSatoshi = 11_000_000 msat, expiryIn: CltvExpiry = CltvExpiry(400_100), accountableIn: Boolean = false, receivedAt: TimestampMilli = TimestampMilli.now()): IncomingPaymentPacket.ChannelRelayPacket = { + def createValidIncomingPacket(payload: IntermediatePayload.ChannelRelay, amountIn: MilliSatoshi = 11_000_000 msat, expiryIn: CltvExpiry = CltvExpiry(400_100), accountableIn: Boolean = false, receivedAt: TimestampMilli = TimestampMilli.now(), incomingChannelId: ByteVector32 = randomBytes32()): IncomingPaymentPacket.ChannelRelayPacket = { val nextPathKey_opt = payload match { case p: ChannelRelay.Blinded => Some(UpdateAddHtlcTlv.PathKey(p.nextPathKey)) case _: ChannelRelay.Standard => None } val tlvs = TlvStream(Set[Option[UpdateAddHtlcTlv]](nextPathKey_opt, if (accountableIn) Some(UpdateAddHtlcTlv.Accountable()) else None).flatten) - val add_ab = UpdateAddHtlc(channelId = randomBytes32(), id = 123456, amountIn, paymentHash, expiryIn, emptyOnionPacket, tlvs) + val add_ab = UpdateAddHtlc(channelId = incomingChannelId, id = 123456, amountIn, paymentHash, expiryIn, emptyOnionPacket, tlvs) ChannelRelayPacket(add_ab, payload, emptyOnionPacket, receivedAt) } 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 cc5cc79629..700456c924 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 @@ -645,6 +645,36 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution } } + test("calculate Blip18 route when the recipient has positive inbound fees") { + // Inbound fees never apply to the final hop: the recipient doesn't relay the payment, so its advertised + // (positive) inbound fees must not exclude the channels towards it from path finding. + val g = GraphWithBalanceEstimates(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(3L, c, d, 1 msat, 10, cltvDelta = CltvExpiryDelta(1)), + makeEdge(4L, d, e, 1 msat, 10, cltvDelta = CltvExpiryDelta(1)), + makeEdge(4L, e, d, 1 msat, 10, cltvDelta = CltvExpiryDelta(1), inboundFeeBase_opt = Some(1 msat), inboundFeeProportionalMillionth_opt = Some(10)) + )), 1 day) + + val Success(route :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true, excludePositiveInboundFees = true) + assert(route2Ids(route) == 1 :: 2 :: 3 :: 4 :: Nil) + } + + test("calculate simple route with a positive inbound fees channel and Blip18 disabled") { + // When bLIP-18 inbound fees are disabled, the path-finder doesn't account for inbound fees at all: it must not + // exclude channels with positive inbound fees either. + val g = GraphWithBalanceEstimates(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)) + )), 1 day) + + val Success(route :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = false, excludePositiveInboundFees = true) + assert(route2Ids(route) == 1 :: 2 :: 3 :: 4 :: Nil) + } + // run tests from RouteCalculationSpec with inbound fees enabled test("calculate simple route") { From 663f225e7d12d2706bbd5dc5e45e1e5f279977cd Mon Sep 17 00:00:00 2001 From: rorp Date: Sat, 4 Jul 2026 15:18:26 -0700 Subject: [PATCH 06/11] Cleanup --- docs/release-notes/eclair-vnext.md | 2 +- .../fr/acinq/eclair/channel/Helpers.scala | 5 +- .../fr/acinq/eclair/channel/fsm/Channel.scala | 8 +- .../scala/fr/acinq/eclair/db/Databases.scala | 27 ++++--- .../acinq/eclair/payment/relay/Relayer.scala | 1 - .../scala/fr/acinq/eclair/router/Graph.scala | 54 +++++--------- .../eclair/router/RouteCalculation.scala | 73 +++++-------------- .../channel/states/e/NormalStateSpec.scala | 20 +++++ .../router/Blip18FinalizeRouteSpec.scala | 2 +- .../fr/acinq/eclair/router/GraphSpec.scala | 4 +- 10 files changed, 88 insertions(+), 108 deletions(-) diff --git a/docs/release-notes/eclair-vnext.md b/docs/release-notes/eclair-vnext.md index 3f11da7e61..3efafdcf55 100644 --- a/docs/release-notes/eclair-vnext.md +++ b/docs/release-notes/eclair-vnext.md @@ -23,7 +23,7 @@ The routing logic considers inbound fees during route selection if enabled. New positive inbound fees from route finding when configured. The relay and route calculation logic now computes total fees as the sum of the regular (outbound) and inbound fees when applicable. -The wire protocol is updated to include the new TLV (0x55555) type for bLIP-18 inbound fees in ChannelUpdate messages. +The wire protocol is updated to include the new TLV (type 55555) for bLIP-18 inbound fees in ChannelUpdate messages. Code that (de)serializes channel updates now handles these new fields. Inbound fees are stored per peer in a new database: a separate `inboundfees.sqlite` file when using sqlite, or a new diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala index 475f0b1791..8b3e9b4dd7 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala @@ -395,7 +395,10 @@ object Helpers { def getRelayFees(nodeParams: NodeParams, remoteNodeId: PublicKey, announceChannel: Boolean): (RelayFees, Option[InboundFees]) = { val defaultFees = nodeParams.relayParams.defaultFees(announceChannel) - (nodeParams.db.peers.getRelayFees(remoteNodeId).getOrElse(defaultFees), nodeParams.db.inboundFees.getInboundFees(remoteNodeId)) + val relayFees = nodeParams.db.peers.getRelayFees(remoteNodeId).getOrElse(defaultFees) + // We only query the inbound fees database when bLIP-18 support is enabled, to avoid a useless db lookup otherwise. + val inboundFees_opt = if (nodeParams.routerConf.blip18InboundFees) nodeParams.db.inboundFees.getInboundFees(remoteNodeId) else None + (relayFees, inboundFees_opt) } object Funding { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala index 711d5e9d31..c7bf30ea50 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala @@ -934,7 +934,9 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall } case Event(c: CMD_UPDATE_RELAY_FEE, d: DATA_NORMAL) => - val channelUpdate1 = Helpers.channelUpdate(nodeParams, scidForChannelUpdate(d), d.commitments, Relayer.RelayFees(c.feeBase, c.feeProportionalMillionths), enable = true, InboundFees.fromOptions(c.inboundFeeBase_opt, c.inboundFeeProportionalMillionths_opt)) + // If inbound fees are omitted, we preserve the ones currently advertised in our channel_update. + val inboundFees_opt = InboundFees.fromOptions(c.inboundFeeBase_opt, c.inboundFeeProportionalMillionths_opt).orElse(d.channelUpdate.blip18InboundFees_opt) + val channelUpdate1 = Helpers.channelUpdate(nodeParams, scidForChannelUpdate(d), d.commitments, Relayer.RelayFees(c.feeBase, c.feeProportionalMillionths), enable = true, inboundFees_opt) log.debug(s"updating relay fees: prev={} next={}", d.channelUpdate.toStringShort, channelUpdate1.toStringShort) val replyTo = if (c.replyTo == ActorRef.noSender) sender() else c.replyTo replyTo ! RES_SUCCESS(c, d.channelId) @@ -3360,7 +3362,9 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall } private def handleUpdateRelayFeeDisconnected(c: CMD_UPDATE_RELAY_FEE, d: DATA_NORMAL) = { - val channelUpdate1 = Helpers.channelUpdate(nodeParams, scidForChannelUpdate(d), d.commitments, Relayer.RelayFees(c.feeBase, c.feeProportionalMillionths), enable = false, InboundFees.fromOptions(c.inboundFeeBase_opt, c.inboundFeeProportionalMillionths_opt)) + // If inbound fees are omitted, we preserve the ones currently advertised in our channel_update. + val inboundFees_opt = InboundFees.fromOptions(c.inboundFeeBase_opt, c.inboundFeeProportionalMillionths_opt).orElse(d.channelUpdate.blip18InboundFees_opt) + val channelUpdate1 = Helpers.channelUpdate(nodeParams, scidForChannelUpdate(d), d.commitments, Relayer.RelayFees(c.feeBase, c.feeProportionalMillionths), enable = false, inboundFees_opt) log.debug(s"updating relay fees: prev={} next={}", d.channelUpdate.toStringShort, channelUpdate1.toStringShort) val replyTo = if (c.replyTo == ActorRef.noSender) sender() else c.replyTo replyTo ! RES_SUCCESS(c, d.channelId) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/Databases.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/Databases.scala index 1127bec941..dcd1e985cd 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/Databases.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/Databases.scala @@ -72,21 +72,28 @@ object Databases extends Logging { inboundFees: SqliteInboundFeesDb, private val backupConnection: Connection) extends Databases with FileBackup { override def backup(backupFile: File): Unit = { - // Inbound fees are stored in a separate database file, which we back up next to the main backup file. Our caller - // only handles the temporary file and atomic move for the main backup file, so we do it ourselves here. - val inboundFeesBackupFile = new File(backupFile.getAbsoluteFile.getParentFile, "inboundfees.sqlite.bak") - val inboundFeesTmpFile = new File(inboundFeesBackupFile.getAbsolutePath.concat(".tmp")) - SqliteUtils.using(inboundFees.sqlite.createStatement()) { - statement => { - statement.executeUpdate(s"backup to ${inboundFeesTmpFile.getAbsolutePath}") - } - } - Files.move(inboundFeesTmpFile.toPath, inboundFeesBackupFile.toPath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE) + // We back up the main database first: it holds our channel state and is critical for funds safety, so its backup + // must not be prevented by a failure of the (much less important) inbound fees backup below. SqliteUtils.using(backupConnection.createStatement()) { statement => { statement.executeUpdate(s"backup to ${backupFile.getAbsolutePath}") } } + // Inbound fees are stored in a separate database file, which we back up next to the main backup file. Our caller + // only handles the temporary file and atomic move for the main backup file, so we do it ourselves here. A failure + // here is logged but not propagated, so that it doesn't prevent the main backup above from being finalized. + try { + val inboundFeesBackupFile = new File(backupFile.getAbsoluteFile.getParentFile, "inboundfees.sqlite.bak") + val inboundFeesTmpFile = new File(inboundFeesBackupFile.getAbsolutePath.concat(".tmp")) + SqliteUtils.using(inboundFees.sqlite.createStatement()) { + statement => { + statement.executeUpdate(s"backup to ${inboundFeesTmpFile.getAbsolutePath}") + } + } + Files.move(inboundFeesTmpFile.toPath, inboundFeesBackupFile.toPath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE) + } catch { + case t: Throwable => logger.warn(s"could not backup the inbound fees database: ${t.getMessage}") + } } } 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 e79d5a93fe..1d1c9d2f4b 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 @@ -32,7 +32,6 @@ import fr.acinq.eclair.payment.receive.MultiPartHandler import fr.acinq.eclair.reputation.{Reputation, ReputationRecorder} import fr.acinq.eclair.wire.protocol._ import fr.acinq.eclair._ -import fr.acinq.eclair.{CltvExpiryDelta, Logs, MilliSatoshi, NodeParams, RealShortChannelId} import grizzled.slf4j.Logging import scala.concurrent.Promise 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 82afd5c8a6..ca6e713b24 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 @@ -269,7 +269,9 @@ object Graph { // find the "spur" path, a sub-path going from the spur node to the target avoiding previously found sub-paths dijkstraShortestPath(g, sourceNode, spurNode, ignoredEdges ++ alreadyExploredEdges, ignoredVertices ++ alreadyExploredVertices, extraEdges, rootPathWeight, boundaries, Features.empty, currentBlockHeight, wr, includeLocalChannelCost, excludePositiveInboundFees, blip18InboundFees) match { case Some(spurPath) => - val completePath = spurPath ++ rootPathEdges + // The root-path edges were enriched relative to their predecessor in the previous shortest path; after + // concatenation with the spur path their actual predecessor changes, so we recompute inbound fees. + val completePath = enrichPathWithInboundFees(spurPath ++ rootPathEdges, g.graph, blip18InboundFees) val candidatePath = WeightedPath(completePath, pathWeight(g.balances, sourceNode, completePath, amount, currentBlockHeight, wr, includeLocalChannelCost, g.graph, blip18InboundFees)) candidates.enqueue(PathWithSpur(candidatePath, i)) case None => () @@ -390,17 +392,14 @@ object Graph { if (targetFound) { val edgePath = new mutable.ArrayBuffer[GraphEdge](RouteCalculation.ROUTE_MAX_LENGTH) var current = bestEdges.get(sourceNode) - var previousEdge: Option[GraphEdge] = None while (current.isDefined) { - val edge = enrichEdgeWithInboundFees(current.get, previousEdge, g.graph, enableInboundFees) - edgePath += edge - previousEdge = Some(edge) - current = bestEdges.get(edge.desc.b) + edgePath += current.get + current = bestEdges.get(current.get.desc.b) if (edgePath.length > RouteCalculation.ROUTE_MAX_LENGTH) { throw InfiniteLoop(edgePath.toSeq) } } - Some(edgePath.toSeq) + Some(enrichPathWithInboundFees(edgePath.toSeq, g.graph, enableInboundFees)) } else { None } @@ -432,7 +431,7 @@ object Graph { currentBlockHeight: BlockHeight, boundaries: PaymentPathWeight => Boolean, excludePositiveInboundFees: Boolean, - enableInboundFees: Boolean = false): Seq[WeightedPath[PaymentPathWeight]] = { + enableInboundFees: Boolean): Seq[WeightedPath[PaymentPathWeight]] = { val paths = new mutable.ArrayBuffer[WeightedPath[PaymentPathWeight]](pathsToFind) val verticesToIgnore = new mutable.HashSet[PublicKey]() verticesToIgnore.addAll(ignoredVertices) @@ -450,35 +449,22 @@ object Graph { } /** - * Enriches an edge with inbound fees from the previous edge's back-edge. - * This is used during path reconstruction to apply BLIP-18 inbound fees. - * - * @param edge the current edge to potentially enrich - * @param previousEdge_opt the previous edge in the path (None for the first edge) - * @param g the graph structure containing back-edges - * @param enableInboundFees whether to enrich with inbound fees - * @return the edge, potentially enriched with inbound fees + * Set each edge's bLIP-18 inbound fees from its predecessor's back-edge (the channel_update the relaying node + * advertises for the channel it receives the payment on). The first edge has no predecessor and thus no inbound fees + * (the sender doesn't charge itself). Any fees already set are recomputed, which is required after concatenating + * sub-paths (e.g. in Yen's algorithm), where a root-path edge may carry fees computed for a different predecessor. */ - private def enrichEdgeWithInboundFees(edge: GraphEdge, previousEdge_opt: Option[GraphEdge], g: GraphStructure.DirectedGraph, enableInboundFees: Boolean): GraphEdge = { + private[router] def enrichPathWithInboundFees(path: Seq[GraphEdge], g: GraphStructure.DirectedGraph, enableInboundFees: Boolean): Seq[GraphEdge] = { if (!enableInboundFees) { - edge + path } else { - previousEdge_opt match { - case Some(prevEdge) => - edge.params match { - case params: HopRelayParams.FromAnnouncement if params.inboundFees_opt.isEmpty => - // Look up the previous edge's back-edge and extract its BLIP18 fees - val inboundFees_opt = g.getBackEdge(prevEdge.desc) - .flatMap(_.getChannelUpdate) - .flatMap(_.blip18InboundFees_opt) - if (inboundFees_opt.isDefined) { - edge.copy(params = params.copy(inboundFees_opt = inboundFees_opt)) - } else { - edge - } - case _ => edge - } - case None => edge // First edge has no inbound fees + // We pair every edge with its predecessor (None for the first one). Enrichment only changes params, not desc, so + // pairing with the un-enriched predecessor yields the same back-edge lookup as threading the enriched one would. + path.zip(None +: path.map(Some(_))).map { + case (edge, previousEdge_opt) => edge.params match { + case params: HopRelayParams.FromAnnouncement => edge.copy(params = params.copy(inboundFees_opt = previousEdge_opt.flatMap(getInboundFees(g, _)))) + case _ => edge + } } } } 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 140f186aab..cdac892813 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 @@ -65,29 +65,6 @@ object RouteCalculation { } } - def enrichRouteWithInboundFees(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) - } - } - Logs.withMdc(log)(Logs.mdc( category_opt = Some(LogCategory.PAYMENT), parentPaymentId_opt = fr.paymentContext.map(_.parentId), @@ -98,6 +75,15 @@ object RouteCalculation { val extraEdges = fr.extraEdges.map(GraphEdge(_)) val g = extraEdges.foldLeft(d.graphWithBalances.graph) { case (g: DirectedGraph, e: GraphEdge) => g.addEdge(e) } + def finalizeAndReply(amount: MilliSatoshi, edges: Seq[GraphEdge], maxFee_opt: Option[MilliSatoshi]): Unit = { + val hops = Graph.enrichPathWithInboundFees(edges, g, fr.blip18InboundFees).map(e => ChannelHop(getEdgeRelayScid(d, localNodeId, e), e.desc.a, e.desc.b, e.params)) + val route = validatePositiveInboundFees(Route(amount, hops, None), fr.blip18InboundFees && fr.excludePositiveInboundFees).flatMap(validateMaxRouteFee(_, maxFee_opt)) + route match { + case Success(r) => fr.replyTo ! RouteResponse(r :: Nil) + case Failure(f) => fr.replyTo ! PaymentRouteNotFound(f) + } + } + fr.route match { case PredefinedNodeRoute(amount, hops, maxFee_opt) => // split into sublists [(a,b),(b,c), ...] then get the edges between each of those pairs @@ -105,27 +91,14 @@ object RouteCalculation { 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)) - val route = if (fr.blip18InboundFees) { - validatePositiveInboundFees(enrichRouteWithInboundFees(amount, hops, g), fr.excludePositiveInboundFees) - } else { - Success(Route(amount, hops, None)) - } - route match { - case Success(r) => - validateMaxRouteFee(r, maxFee_opt) match { - case Success(validatedRoute) => fr.replyTo ! RouteResponse(validatedRoute :: Nil) - case Failure(f) => fr.replyTo ! PaymentRouteNotFound(f) - } - case Failure(f) => fr.replyTo ! PaymentRouteNotFound(f) - } + finalizeAndReply(amount, selectedEdges, maxFee_opt) case _ => // some nodes in the supplied route aren't connected in our graph fr.replyTo ! PaymentRouteNotFound(new IllegalArgumentException("Not all the nodes in the supplied route are connected with public channels")) } case PredefinedChannelRoute(amount, targetNodeId, shortChannelIds, maxFee_opt) => - val (end, hops) = shortChannelIds.foldLeft((localNodeId, Seq.empty[ChannelHop])) { - case ((currentNode, previousHops), shortChannelId) => + val (end, edges) = shortChannelIds.foldLeft((localNodeId, Seq.empty[GraphEdge])) { + case ((currentNode, previousEdges), shortChannelId) => val channelDesc_opt = d.resolve(shortChannelId) match { case Some(c: PublicChannel) => currentNode match { case c.nodeId1 => Some(ChannelDesc(shortChannelId, c.nodeId1, c.nodeId2)) @@ -140,26 +113,14 @@ object RouteCalculation { case None => extraEdges.find(e => e.desc.shortChannelId == shortChannelId && e.desc.a == currentNode).map(_.desc) } channelDesc_opt.flatMap(c => g.getEdge(c)) match { - case Some(edge) => (edge.desc.b, previousHops :+ ChannelHop(getEdgeRelayScid(d, localNodeId, edge), edge.desc.a, edge.desc.b, edge.params)) - case None => (currentNode, previousHops) + case Some(edge) => (edge.desc.b, previousEdges :+ edge) + case None => (currentNode, previousEdges) } } - if (end != targetNodeId || hops.length != shortChannelIds.length) { + if (end != targetNodeId || edges.length != shortChannelIds.length) { fr.replyTo ! PaymentRouteNotFound(new IllegalArgumentException("The sequence of channels provided cannot be used to build a route to the target node")) } else { - val route = if (fr.blip18InboundFees) { - validatePositiveInboundFees(enrichRouteWithInboundFees(amount, hops, g), fr.excludePositiveInboundFees) - } else { - Success(Route(amount, hops, None)) - } - route match { - case Success(r) => - validateMaxRouteFee(r, maxFee_opt) match { - case Success(validatedRoute) => fr.replyTo ! RouteResponse(validatedRoute :: Nil) - case Failure(f) => fr.replyTo ! PaymentRouteNotFound(f) - } - case Failure(f) => fr.replyTo ! PaymentRouteNotFound(f) - } + finalizeAndReply(amount, edges, maxFee_opt) } } @@ -285,7 +246,7 @@ object RouteCalculation { weight.length <= ROUTE_MAX_LENGTH && weight.cltv <= r.routeParams.boundaries.maxCltv } - val routes = Graph.routeBlindingPaths(d.graphWithBalances, r.source, r.target, r.amount, r.ignore.channels, r.ignore.nodes, r.pathsToFind, r.routeParams.heuristics, currentBlockHeight, boundaries, r.excludePositiveInboundFees) + val routes = Graph.routeBlindingPaths(d.graphWithBalances, r.source, r.target, r.amount, r.ignore.channels, r.ignore.nodes, r.pathsToFind, r.routeParams.heuristics, currentBlockHeight, boundaries, r.excludePositiveInboundFees, r.blip18InboundFees) if (routes.isEmpty) { r.replyTo ! PaymentRouteNotFound(RouteNotFound) } else { diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala index a372127371..b32e8fdca7 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala @@ -2237,6 +2237,26 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with alice2relayer.expectNoMessage(100 millis) } + test("recv CMD_UPDATE_RELAY_FEE (preserves inbound fees when omitted)") { f => + import f._ + val sender = TestProbe() + val newFeeBaseMsat = TestConstants.Alice.nodeParams.relayParams.publicChannelFees.feeBase * 2 + val newFeeProportionalMillionth = TestConstants.Alice.nodeParams.relayParams.publicChannelFees.feeProportionalMillionths * 2 + // We first set inbound fees along with the outbound fees. + sender.send(alice, CMD_UPDATE_RELAY_FEE(ActorRef.noSender, newFeeBaseMsat, newFeeProportionalMillionth, Some(-100 msat), Some(-50))) + sender.expectMsgType[RES_SUCCESS[CMD_UPDATE_RELAY_FEE]] + val localUpdate1 = channelUpdateListener.expectMsgType[LocalChannelUpdate] + assert(localUpdate1.channelUpdate.blip18InboundFees_opt.contains(InboundFees(-100 msat, -50))) + + // We then update only the outbound fees: the inbound fees must be preserved in the advertised channel_update. + sender.send(alice, CMD_UPDATE_RELAY_FEE(ActorRef.noSender, newFeeBaseMsat * 2, newFeeProportionalMillionth * 2)) + sender.expectMsgType[RES_SUCCESS[CMD_UPDATE_RELAY_FEE]] + val localUpdate2 = channelUpdateListener.expectMsgType[LocalChannelUpdate] + assert(localUpdate2.channelUpdate.feeBaseMsat == newFeeBaseMsat * 2) + assert(localUpdate2.channelUpdate.blip18InboundFees_opt.contains(InboundFees(-100 msat, -50))) + alice2relayer.expectNoMessage(100 millis) + } + def testCmdClose(f: FixtureParam, script_opt: Option[ByteVector]): Unit = { import f._ val sender = TestProbe() diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/router/Blip18FinalizeRouteSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/router/Blip18FinalizeRouteSpec.scala index d2d7310596..0df9e00eee 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/router/Blip18FinalizeRouteSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/router/Blip18FinalizeRouteSpec.scala @@ -390,4 +390,4 @@ object Blip18FinalizeRouteSpec { tlvStream = tlvStream ) } -} \ No newline at end of file +} diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/router/GraphSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/router/GraphSpec.scala index 1063c24289..59259c639a 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/router/GraphSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/router/GraphSpec.scala @@ -524,13 +524,13 @@ class GraphSpec extends AnyFunSuite { .addOrUpdateVertex(makeNodeAnnouncement(priv_h, "H", Color(0, 0, 0), Nil, Features(Features.RouteBlinding -> FeatureSupport.Optional))) { - val paths = routeBlindingPaths(GraphWithBalanceEstimates(graph, 1 day), a, h, 20_000_000 msat, Set.empty, Set.empty, pathsToFind = 3, HeuristicsConstants(0, RelayFees(0 msat, 0), RelayFees(0 msat, 0), useLogProbability = false, usePastRelaysData = false), BlockHeight(793397), _ => true, false) + val paths = routeBlindingPaths(GraphWithBalanceEstimates(graph, 1 day), a, h, 20_000_000 msat, Set.empty, Set.empty, pathsToFind = 3, HeuristicsConstants(0, RelayFees(0 msat, 0), RelayFees(0 msat, 0), useLogProbability = false, usePastRelaysData = false), BlockHeight(793397), _ => true, excludePositiveInboundFees = false, enableInboundFees = false) assert(paths.length == 2) assert(paths(0).path.map(_.desc.a) == Seq(a, b)) assert(paths(1).path.map(_.desc.a) == Seq(a, e, f)) } { - val paths = routeBlindingPaths(GraphWithBalanceEstimates(graph, 1 day), c, h, 20_000_000 msat, Set.empty, Set.empty, pathsToFind = 3, HeuristicsConstants(0, RelayFees(0 msat, 0), RelayFees(0 msat, 0), useLogProbability = false, usePastRelaysData = false), BlockHeight(793397), _ => true, false) + val paths = routeBlindingPaths(GraphWithBalanceEstimates(graph, 1 day), c, h, 20_000_000 msat, Set.empty, Set.empty, pathsToFind = 3, HeuristicsConstants(0, RelayFees(0 msat, 0), RelayFees(0 msat, 0), useLogProbability = false, usePastRelaysData = false), BlockHeight(793397), _ => true, excludePositiveInboundFees = false, enableInboundFees = false) assert(paths.length == 1) assert(paths(0).path.map(_.desc.a) == Seq(c, a, b)) } From 8db1181f6cabe07bab458c75ef96ab47ac592494 Mon Sep 17 00:00:00 2001 From: rorp Date: Sun, 5 Jul 2026 05:12:50 -0700 Subject: [PATCH 07/11] Bundle bLIP-18 routing flags into Blip18Params --- .../main/scala/fr/acinq/eclair/Eclair.scala | 4 +- .../scala/fr/acinq/eclair/NodeParams.scala | 6 +- .../fr/acinq/eclair/channel/Helpers.scala | 2 +- .../payment/offer/DefaultOfferHandler.scala | 2 +- .../eclair/payment/relay/ChannelRelayer.scala | 2 +- .../send/MultiPartPaymentLifecycle.scala | 2 +- .../payment/send/PaymentLifecycle.scala | 12 +- .../remote/EclairInternalsSerializer.scala | 7 +- .../scala/fr/acinq/eclair/router/Graph.scala | 63 ++-- .../eclair/router/RouteCalculation.scala | 35 +- .../scala/fr/acinq/eclair/router/Router.scala | 27 +- .../fr/acinq/eclair/EclairImplSpec.scala | 2 +- .../scala/fr/acinq/eclair/TestConstants.scala | 6 +- .../payment/relay/ChannelRelayerSpec.scala | 2 +- .../router/Blip18FinalizeRouteSpec.scala | 10 +- .../router/Blip18RouteCalculationSpec.scala | 300 +++++++++--------- .../fr/acinq/eclair/router/GraphSpec.scala | 6 +- .../eclair/router/RouteCalculationSpec.scala | 4 +- 18 files changed, 249 insertions(+), 243 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala index 4f2a9f4499..d495d37010 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala @@ -317,7 +317,7 @@ class EclairImpl(val appKit: Kit) extends Eclair with Logging with SpendFromChan updateRelayFee(nodes, feeBaseMsat, feeProportionalMillionths, None, None) override def updateRelayFee(nodes: List[PublicKey], feeBaseMsat: MilliSatoshi, feeProportionalMillionths: Long, inboundFeeBase_opt: Option[MilliSatoshi], inboundFeeProportional_opt: Option[Long])(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_UPDATE_RELAY_FEE]]]] = { - if ((inboundFeeBase_opt.isDefined || inboundFeeProportional_opt.isDefined) && !appKit.nodeParams.routerConf.blip18InboundFees) { + if ((inboundFeeBase_opt.isDefined || inboundFeeProportional_opt.isDefined) && !appKit.nodeParams.routerConf.blip18.enableInboundFees) { Future.failed(new IllegalArgumentException("Cannot specify inbound fees when bLIP-18 support is disabled")) } else { for (nodeId <- nodes) { @@ -502,7 +502,7 @@ class EclairImpl(val appKit: Kit) extends Eclair with Logging with SpendFromChan for { ignoredChannels <- getChannelDescs(ignoreShortChannelIds.toSet) ignore = Ignore(ignoreNodeIds.toSet, ignoredChannels) - response <- appKit.router.toTyped.ask[PaymentRouteResponse](replyTo => RouteRequest(replyTo, sourceNodeId, target, routeParams1, ignore, blip18InboundFees = appKit.nodeParams.routerConf.blip18InboundFees, excludePositiveInboundFees = appKit.nodeParams.routerConf.excludePositiveInboundFees)).flatMap { + response <- appKit.router.toTyped.ask[PaymentRouteResponse](replyTo => RouteRequest(replyTo, sourceNodeId, target, routeParams1, ignore, blip18 = appKit.nodeParams.routerConf.blip18)).flatMap { case r: RouteResponse => Future.successful(r) case PaymentRouteNotFound(error) => Future.failed(error) } 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 c067cacb11..6b171dc743 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala @@ -686,8 +686,10 @@ object NodeParams extends Logging { pathFindingExperimentConf = getPathFindingExperimentConf(config.getConfig("router.path-finding.experiments")), messageRouteParams = getMessageRouteParams(config.getConfig("router.message-path-finding")), balanceEstimateHalfLife = FiniteDuration(config.getDuration("router.balance-estimate-half-life").getSeconds, TimeUnit.SECONDS), - blip18InboundFees = config.getBoolean("router.path-finding.blip18-inbound-fees"), - excludePositiveInboundFees = config.getBoolean("router.path-finding.exclude-channels-with-positive-inbound-fees"), + blip18 = Router.Blip18Params( + enableInboundFees = config.getBoolean("router.path-finding.blip18-inbound-fees"), + excludePositiveInboundFees = config.getBoolean("router.path-finding.exclude-channels-with-positive-inbound-fees"), + ), ), socksProxy_opt = socksProxy_opt, maxPaymentAttempts = config.getInt("max-payment-attempts"), diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala index 8b3e9b4dd7..5343dcb52b 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala @@ -397,7 +397,7 @@ object Helpers { val defaultFees = nodeParams.relayParams.defaultFees(announceChannel) val relayFees = nodeParams.db.peers.getRelayFees(remoteNodeId).getOrElse(defaultFees) // We only query the inbound fees database when bLIP-18 support is enabled, to avoid a useless db lookup otherwise. - val inboundFees_opt = if (nodeParams.routerConf.blip18InboundFees) nodeParams.db.inboundFees.getInboundFees(remoteNodeId) else None + val inboundFees_opt = if (nodeParams.routerConf.blip18.enableInboundFees) nodeParams.db.inboundFees.getInboundFees(remoteNodeId) else None (relayFees, inboundFees_opt) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/DefaultOfferHandler.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/DefaultOfferHandler.scala index 123ce4a0af..50ee71e842 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/DefaultOfferHandler.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/DefaultOfferHandler.scala @@ -89,7 +89,7 @@ object DefaultOfferHandler { maxRouteLength = nodeParams.offersConfig.paymentPathLength, maxCltv = nodeParams.offersConfig.paymentPathCltvExpiryDelta )) - router ! BlindedRouteRequest(context.messageAdapter(WrappedRouteResponse), blindedPathFirstNodeId, nodeParams.nodeId, invoiceRequest.amount, routeParams, nodeParams.offersConfig.paymentPathCount, blip18InboundFees = nodeParams.routerConf.blip18InboundFees, excludePositiveInboundFees = nodeParams.routerConf.excludePositiveInboundFees) + router ! BlindedRouteRequest(context.messageAdapter(WrappedRouteResponse), blindedPathFirstNodeId, nodeParams.nodeId, invoiceRequest.amount, routeParams, nodeParams.offersConfig.paymentPathCount, blip18 = nodeParams.routerConf.blip18) waitForRoute(nodeParams, replyTo, invoiceRequest, blindedPathFirstNodeId, context) } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelayer.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelayer.scala index 8a19098b11..b7edb5b55a 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelayer.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelayer.scala @@ -84,7 +84,7 @@ object ChannelRelayer { case None => Map.empty } // bLIP-18 inbound fees advertised in our channel_update for the incoming channel apply to this relay. - val inboundFees_opt = if (nodeParams.routerConf.blip18InboundFees) { + val inboundFees_opt = if (nodeParams.routerConf.blip18.enableInboundFees) { channels.get(channelRelayPacket.add.channelId).flatMap(_.channelUpdate.blip18InboundFees_opt) } else { None diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/MultiPartPaymentLifecycle.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/MultiPartPaymentLifecycle.scala index 477bee2666..f9662e0b68 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/MultiPartPaymentLifecycle.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/MultiPartPaymentLifecycle.scala @@ -372,7 +372,7 @@ object MultiPartPaymentLifecycle { case class PaymentSucceeded(request: SendMultiPartPayment, preimage: ByteVector32, parts: Seq[PaymentPart], pending: Set[UUID], remainingAttribution_opt: Option[ByteVector]) extends Data private def createRouteRequest(replyTo: ActorRef, nodeParams: NodeParams, routeParams: RouteParams, d: PaymentProgress, cfg: SendPaymentConfig): RouteRequest = { - RouteRequest(replyTo.toTyped, nodeParams.nodeId, d.request.recipient, routeParams, d.ignore, allowMultiPart = true, d.pending.values.toSeq, Some(cfg.paymentContext), blip18InboundFees = nodeParams.routerConf.blip18InboundFees, excludePositiveInboundFees = nodeParams.routerConf.excludePositiveInboundFees) + RouteRequest(replyTo.toTyped, nodeParams.nodeId, d.request.recipient, routeParams, d.ignore, allowMultiPart = true, d.pending.values.toSeq, Some(cfg.paymentContext), blip18 = nodeParams.routerConf.blip18) } private def createChildPayment(replyTo: ActorRef, route: Route, request: SendMultiPartPayment): SendPaymentToRoute = { 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 4dd267762a..96b4d94ef4 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 @@ -55,7 +55,7 @@ class PaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, router: A case Event(request: SendPaymentToRoute, WaitingForRequest) => log.debug("sending {} to route {}", request.amount, request.printRoute()) request.route.fold( - hops => router ! FinalizeRoute(self, hops, request.recipient.extraEdges, paymentContext = Some(cfg.paymentContext), nodeParams.routerConf.blip18InboundFees, nodeParams.routerConf.excludePositiveInboundFees), + hops => router ! FinalizeRoute(self, hops, request.recipient.extraEdges, paymentContext = Some(cfg.paymentContext), blip18 = nodeParams.routerConf.blip18), route => self ! RouteResponse(route :: Nil) ) if (cfg.storeInDb) { @@ -65,7 +65,7 @@ class PaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, router: A case Event(request: SendPaymentToNode, WaitingForRequest) => log.debug("sending {} to {}", request.amount, request.recipient.nodeId) - router ! RouteRequest(self, nodeParams.nodeId, request.recipient, request.routeParams, paymentContext = Some(cfg.paymentContext), blip18InboundFees = nodeParams.routerConf.blip18InboundFees, excludePositiveInboundFees = nodeParams.routerConf.excludePositiveInboundFees) + router ! RouteRequest(self, nodeParams.nodeId, request.recipient, request.routeParams, paymentContext = Some(cfg.paymentContext), blip18 = nodeParams.routerConf.blip18) if (cfg.storeInDb) { paymentsDb.addOutgoingPayment(OutgoingPayment(id, cfg.parentId, cfg.externalId, paymentHash, cfg.paymentType, request.amount, request.recipient.totalAmount, request.recipient.nodeId, TimestampMilli.now(), cfg.invoice, cfg.payerKey_opt, OutgoingPaymentStatus.Pending)) } @@ -161,7 +161,7 @@ class PaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, router: A data.request match { case request: SendPaymentToNode => val ignore1 = PaymentFailure.updateIgnored(failure, data.ignore) - router ! RouteRequest(self, nodeParams.nodeId, data.recipient, request.routeParams, ignore1, paymentContext = Some(cfg.paymentContext), blip18InboundFees = nodeParams.routerConf.blip18InboundFees, excludePositiveInboundFees = nodeParams.routerConf.excludePositiveInboundFees) + router ! RouteRequest(self, nodeParams.nodeId, data.recipient, request.routeParams, ignore1, paymentContext = Some(cfg.paymentContext), blip18 = nodeParams.routerConf.blip18) goto(WAITING_FOR_ROUTE) using WaitingForRoute(data.request, data.failures :+ failure, ignore1) case _: SendPaymentToRoute => log.error("unexpected retry during SendPaymentToRoute") @@ -269,7 +269,7 @@ class PaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, router: A log.error("unexpected retry during SendPaymentToRoute") stop(FSM.Normal) case request: SendPaymentToNode => - router ! RouteRequest(self, nodeParams.nodeId, recipient1, request.routeParams, ignore1, paymentContext = Some(cfg.paymentContext), blip18InboundFees = nodeParams.routerConf.blip18InboundFees, excludePositiveInboundFees = nodeParams.routerConf.excludePositiveInboundFees) + router ! RouteRequest(self, nodeParams.nodeId, recipient1, request.routeParams, ignore1, paymentContext = Some(cfg.paymentContext), blip18 = nodeParams.routerConf.blip18) goto(WAITING_FOR_ROUTE) using WaitingForRoute(request.copy(recipient = recipient1), failures :+ failure, ignore1) } } else { @@ -280,7 +280,7 @@ class PaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, router: A log.error("unexpected retry during SendPaymentToRoute") stop(FSM.Normal) case request: SendPaymentToNode => - router ! RouteRequest(self, nodeParams.nodeId, recipient, request.routeParams, ignore + nodeId, paymentContext = Some(cfg.paymentContext), blip18InboundFees = nodeParams.routerConf.blip18InboundFees, excludePositiveInboundFees = nodeParams.routerConf.excludePositiveInboundFees) + router ! RouteRequest(self, nodeParams.nodeId, recipient, request.routeParams, ignore + nodeId, paymentContext = Some(cfg.paymentContext), blip18 = nodeParams.routerConf.blip18) goto(WAITING_FOR_ROUTE) using WaitingForRoute(request, failures :+ failure, ignore + nodeId) } } @@ -294,7 +294,7 @@ class PaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, router: A log.error("unexpected retry during SendPaymentToRoute") stop(FSM.Normal) case request: SendPaymentToNode => - router ! RouteRequest(self, nodeParams.nodeId, recipient, request.routeParams, ignore1, paymentContext = Some(cfg.paymentContext), blip18InboundFees = nodeParams.routerConf.blip18InboundFees, excludePositiveInboundFees = nodeParams.routerConf.excludePositiveInboundFees) + router ! RouteRequest(self, nodeParams.nodeId, recipient, request.routeParams, ignore1, paymentContext = Some(cfg.paymentContext), blip18 = nodeParams.routerConf.blip18) goto(WAITING_FOR_ROUTE) using WaitingForRoute(request, failures :+ failure, ignore1) } case Right(e@Sphinx.DecryptedFailurePacket(nodeId, _, failureMessage)) => 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 e6f7c364e1..12041dda96 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 @@ -103,6 +103,10 @@ object EclairInternalsSerializer { ("peerLimit" | int32) :: ("whitelist" | listOfN(uint16, publicKey).xmap[Set[PublicKey]](_.toSet, _.toList))).as[Router.SyncConf] + val blip18ParamsCodec: Codec[Router.Blip18Params] = ( + ("enableInboundFees" | bool(8)) :: + ("excludePositiveInboundFees" | bool(8))).as[Router.Blip18Params] + val routerConfCodec: Codec[RouterConf] = ( ("watchSpentWindow" | finiteDurationCodec) :: ("channelSpentSpliceDelay" | int32) :: @@ -112,8 +116,7 @@ object EclairInternalsSerializer { ("pathFindingExperimentConf" | pathFindingExperimentConfCodec) :: ("messageRouteParams" | messageRouteParamsCodec) :: ("balanceEstimateHalfLife" | finiteDurationCodec) :: - ("blip18InboundFees" | bool(8)) :: - ("excludePositiveInboundFees" | bool(8))).as[RouterConf] + ("blip18" | blip18ParamsCodec)).as[RouterConf] val overrideFeaturesListCodec: Codec[List[(PublicKey, Features[Feature])]] = listOfN(uint16, publicKey ~ lengthPrefixedFeaturesCodec) 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 ca6e713b24..9c4b4fd2b1 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 @@ -89,9 +89,9 @@ object Graph { * @param currentBlockHeight the height of the chain tip (latest block). * @param includeLocalChannelCost if the path is for relaying and we need to include the cost of the local channel * @param inbound_opt inbound fees - * @param enableInboundFees whether to include BLIP-18 inbound fees in weight calculation + * @param blip18 bLIP-18 inbound fees settings */ - def addEdgeWeight(sender: PublicKey, edge: GraphEdge, balance: BalanceEstimate, prev: RichWeight, currentBlockHeight: BlockHeight, includeLocalChannelCost: Boolean, inbound_opt: Option[Relayer.InboundFees], enableInboundFees: Boolean): RichWeight + def addEdgeWeight(sender: PublicKey, edge: GraphEdge, balance: BalanceEstimate, prev: RichWeight, currentBlockHeight: BlockHeight, includeLocalChannelCost: Boolean, inbound_opt: Option[Relayer.InboundFees], blip18: Blip18Params): RichWeight } /** @@ -105,9 +105,9 @@ object Graph { * @param usePastRelaysData use data from past relays to estimate the balance of the channels */ case class HeuristicsConstants(lockedFundsRisk: Double, failureFees: RelayFees, hopFees: RelayFees, useLogProbability: Boolean, usePastRelaysData: Boolean) extends WeightRatios[PaymentPathWeight] { - override def addEdgeWeight(sender: PublicKey, edge: GraphEdge, balance: BalanceEstimate, prev: PaymentPathWeight, currentBlockHeight: BlockHeight, includeLocalChannelCost: Boolean, inbound_opt: Option[Relayer.InboundFees], enableInboundFees: Boolean): PaymentPathWeight = { + override def addEdgeWeight(sender: PublicKey, edge: GraphEdge, balance: BalanceEstimate, prev: PaymentPathWeight, currentBlockHeight: BlockHeight, includeLocalChannelCost: Boolean, inbound_opt: Option[Relayer.InboundFees], blip18: Blip18Params): PaymentPathWeight = { val isSenderEdge = edge.desc.a == sender && !includeLocalChannelCost - val (totalAmount, edgeOutboundFee) = addEdgeFeesWithInbound(edge, prev, inbound_opt, enableInboundFees, isSenderEdge) + val (totalAmount, edgeOutboundFee) = addEdgeFeesWithInbound(edge, prev, inbound_opt, blip18, isSenderEdge) val fee = totalAmount - prev.amount val totalFees = prev.fees + fee val totalCltv = prev.cltv + edge.params.cltvExpiryDelta @@ -155,7 +155,7 @@ object Graph { require(ageFactor >= 0.0, "ratio-channel-age must be nonnegative") require(capacityFactor >= 0.0, "ratio-channel-capacity must be nonnegative") - override def addEdgeWeight(sender: PublicKey, edge: GraphEdge, balance: BalanceEstimate, prev: MessagePathWeight, currentBlockHeight: BlockHeight, includeLocalChannelCost: Boolean, inbound_opt: Option[Relayer.InboundFees], enableInboundFees: Boolean): MessagePathWeight = { + override def addEdgeWeight(sender: PublicKey, edge: GraphEdge, balance: BalanceEstimate, prev: MessagePathWeight, currentBlockHeight: BlockHeight, includeLocalChannelCost: Boolean, inbound_opt: Option[Relayer.InboundFees], blip18: Blip18Params): MessagePathWeight = { import RoutingHeuristics._ // Every edge is weighted by funding block height where older blocks add less weight. The window considered is 1 year. @@ -225,11 +225,10 @@ object Graph { currentBlockHeight: BlockHeight, boundaries: PaymentPathWeight => Boolean, includeLocalChannelCost: Boolean, - excludePositiveInboundFees: Boolean = false, - blip18InboundFees: Boolean = false): Seq[WeightedPath[PaymentPathWeight]] = { + blip18: Blip18Params = Blip18Params.disabled): Seq[WeightedPath[PaymentPathWeight]] = { // find the shortest path (k = 0) val targetWeight = PaymentPathWeight(amount) - dijkstraShortestPath(g, sourceNode, targetNode, ignoredEdges, ignoredVertices, extraEdges, targetWeight, boundaries, Features.empty, currentBlockHeight, wr, includeLocalChannelCost, excludePositiveInboundFees, blip18InboundFees) match { + dijkstraShortestPath(g, sourceNode, targetNode, ignoredEdges, ignoredVertices, extraEdges, targetWeight, boundaries, Features.empty, currentBlockHeight, wr, includeLocalChannelCost, blip18) match { case None => Seq.empty // if we can't even find a single path, avoid returning a Seq(Seq.empty) case Some(shortestPath) => @@ -240,7 +239,7 @@ object Graph { var allSpurPathsFound = false val shortestPaths = new mutable.Queue[PathWithSpur] - shortestPaths.enqueue(PathWithSpur(WeightedPath(shortestPath, pathWeight(g.balances, sourceNode, shortestPath, amount, currentBlockHeight, wr, includeLocalChannelCost, g.graph, blip18InboundFees)), 0)) + shortestPaths.enqueue(PathWithSpur(WeightedPath(shortestPath, pathWeight(g.balances, sourceNode, shortestPath, amount, currentBlockHeight, wr, includeLocalChannelCost, g.graph, blip18)), 0)) // stores the candidates for the k-th shortest path, sorted by path cost val candidates = new mutable.PriorityQueue[PathWithSpur] @@ -265,14 +264,14 @@ object Graph { val alreadyExploredEdges = shortestPaths.collect { case p if p.p.path.takeRight(i) == rootPathEdges => p.p.path(p.p.path.length - 1 - i).desc }.toSet // we also want to ignore any vertex on the root path to prevent loops val alreadyExploredVertices = rootPathEdges.map(_.desc.b).toSet - val rootPathWeight = pathWeight(g.balances, sourceNode, rootPathEdges, amount, currentBlockHeight, wr, includeLocalChannelCost, g.graph, blip18InboundFees) + val rootPathWeight = pathWeight(g.balances, sourceNode, rootPathEdges, amount, currentBlockHeight, wr, includeLocalChannelCost, g.graph, blip18) // find the "spur" path, a sub-path going from the spur node to the target avoiding previously found sub-paths - dijkstraShortestPath(g, sourceNode, spurNode, ignoredEdges ++ alreadyExploredEdges, ignoredVertices ++ alreadyExploredVertices, extraEdges, rootPathWeight, boundaries, Features.empty, currentBlockHeight, wr, includeLocalChannelCost, excludePositiveInboundFees, blip18InboundFees) match { + dijkstraShortestPath(g, sourceNode, spurNode, ignoredEdges ++ alreadyExploredEdges, ignoredVertices ++ alreadyExploredVertices, extraEdges, rootPathWeight, boundaries, Features.empty, currentBlockHeight, wr, includeLocalChannelCost, blip18) match { case Some(spurPath) => // The root-path edges were enriched relative to their predecessor in the previous shortest path; after // concatenation with the spur path their actual predecessor changes, so we recompute inbound fees. - val completePath = enrichPathWithInboundFees(spurPath ++ rootPathEdges, g.graph, blip18InboundFees) - val candidatePath = WeightedPath(completePath, pathWeight(g.balances, sourceNode, completePath, amount, currentBlockHeight, wr, includeLocalChannelCost, g.graph, blip18InboundFees)) + val completePath = enrichPathWithInboundFees(spurPath ++ rootPathEdges, g.graph, blip18) + val candidatePath = WeightedPath(completePath, pathWeight(g.balances, sourceNode, completePath, amount, currentBlockHeight, wr, includeLocalChannelCost, g.graph, blip18)) candidates.enqueue(PathWithSpur(candidatePath, i)) case None => () } @@ -322,8 +321,7 @@ object Graph { currentBlockHeight: BlockHeight, wr: WeightRatios[RichWeight], includeLocalChannelCost: Boolean, - excludePositiveInboundFees: Boolean, - enableInboundFees: Boolean): Option[Seq[GraphEdge]] = { + blip18: Blip18Params): Option[Seq[GraphEdge]] = { // the graph does not contain source/destination nodes val sourceNotInGraph = !g.graph.containsVertex(sourceNode) && !extraEdges.exists(_.desc.a == sourceNode) val targetNotInGraph = !g.graph.containsVertex(targetNode) && !extraEdges.exists(_.desc.b == targetNode) @@ -365,12 +363,12 @@ object Graph { (neighbor == sourceNode || g.graph.getVertexFeatures(neighbor).areSupported(nodeFeatures))) { // Inbound fees never apply to the last edge of the path: the recipient doesn't relay the payment, so it // cannot charge relay fees, regardless of what its channel_update advertises. - val inboundFees_opt = if (enableInboundFees && edge.desc.b != targetNode) getInboundFees(g.graph, edge) else None - val excludeEdge = excludePositiveInboundFees && inboundFees_opt.exists(i => i.feeBase.toLong > 0 || i.feeProportionalMillionths > 0) + val inboundFees_opt = if (blip18.enableInboundFees && edge.desc.b != targetNode) getInboundFees(g.graph, edge) else None + val excludeEdge = blip18.excludePositiveInboundFees && inboundFees_opt.exists(i => i.feeBase.toLong > 0 || i.feeProportionalMillionths > 0) if (!excludeEdge) { // 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 = wr.addEdgeWeight(sourceNode, edge, g.balances.get(edge), current.weight, currentBlockHeight, includeLocalChannelCost, inboundFees_opt, enableInboundFees) + val neighborWeight = wr.addEdgeWeight(sourceNode, edge, g.balances.get(edge), current.weight, currentBlockHeight, includeLocalChannelCost, inboundFees_opt, blip18) if (boundaries(neighborWeight)) { val previousNeighborWeight = bestWeights.get(neighbor) // if this path between neighbor and the target has a shorter distance than previously known, we select it @@ -399,7 +397,7 @@ object Graph { throw InfiniteLoop(edgePath.toSeq) } } - Some(enrichPathWithInboundFees(edgePath.toSeq, g.graph, enableInboundFees)) + Some(enrichPathWithInboundFees(edgePath.toSeq, g.graph, blip18)) } else { None } @@ -412,7 +410,7 @@ object Graph { boundaries: MessagePathWeight => Boolean, currentBlockHeight: BlockHeight, wr: MessageWeightRatios): Option[Seq[GraphEdge]] = - dijkstraShortestPath(g, sourceNode, targetNode, ignoredEdges = Set.empty, ignoredVertices, extraEdges = Set.empty, MessagePathWeight.zero, boundaries, Features(Features.OnionMessages -> FeatureSupport.Mandatory), currentBlockHeight, wr, includeLocalChannelCost = true, excludePositiveInboundFees = false, enableInboundFees = false) + dijkstraShortestPath(g, sourceNode, targetNode, ignoredEdges = Set.empty, ignoredVertices, extraEdges = Set.empty, MessagePathWeight.zero, boundaries, Features(Features.OnionMessages -> FeatureSupport.Mandatory), currentBlockHeight, wr, includeLocalChannelCost = true, Blip18Params.disabled) /** * Find non-overlapping (no vertices shared) payment paths that support route blinding @@ -430,15 +428,14 @@ object Graph { wr: WeightRatios[PaymentPathWeight], currentBlockHeight: BlockHeight, boundaries: PaymentPathWeight => Boolean, - excludePositiveInboundFees: Boolean, - enableInboundFees: Boolean): Seq[WeightedPath[PaymentPathWeight]] = { + blip18: Blip18Params): Seq[WeightedPath[PaymentPathWeight]] = { val paths = new mutable.ArrayBuffer[WeightedPath[PaymentPathWeight]](pathsToFind) val verticesToIgnore = new mutable.HashSet[PublicKey]() verticesToIgnore.addAll(ignoredVertices) for (_ <- 1 to pathsToFind) { - dijkstraShortestPath(g, sourceNode, targetNode, ignoredEdges, verticesToIgnore.toSet, extraEdges = Set.empty, PaymentPathWeight(amount), boundaries, Features(Features.RouteBlinding -> FeatureSupport.Mandatory), currentBlockHeight, wr, includeLocalChannelCost = true, excludePositiveInboundFees, enableInboundFees) match { + dijkstraShortestPath(g, sourceNode, targetNode, ignoredEdges, verticesToIgnore.toSet, extraEdges = Set.empty, PaymentPathWeight(amount), boundaries, Features(Features.RouteBlinding -> FeatureSupport.Mandatory), currentBlockHeight, wr, includeLocalChannelCost = true, blip18) match { case Some(path) => - val weight = pathWeight(g.balances, sourceNode, path, amount, currentBlockHeight, wr, includeLocalChannelCost = true, g.graph, enableInboundFees) + val weight = pathWeight(g.balances, sourceNode, path, amount, currentBlockHeight, wr, includeLocalChannelCost = true, g.graph, blip18) paths += WeightedPath(path, weight) // Additional paths must keep using the source and target nodes, but shouldn't use any of the same intermediate nodes. verticesToIgnore.addAll(path.drop(1).map(_.desc.a)) @@ -454,8 +451,8 @@ object Graph { * (the sender doesn't charge itself). Any fees already set are recomputed, which is required after concatenating * sub-paths (e.g. in Yen's algorithm), where a root-path edge may carry fees computed for a different predecessor. */ - private[router] def enrichPathWithInboundFees(path: Seq[GraphEdge], g: GraphStructure.DirectedGraph, enableInboundFees: Boolean): Seq[GraphEdge] = { - if (!enableInboundFees) { + private[router] def enrichPathWithInboundFees(path: Seq[GraphEdge], g: GraphStructure.DirectedGraph, blip18: Blip18Params): Seq[GraphEdge] = { + if (!blip18.enableInboundFees) { path } else { // We pair every edge with its predecessor (None for the first one). Enrichment only changes params, not desc, so @@ -498,13 +495,13 @@ object Graph { * @param edge the edge we want to cross (A→B) * @param prev weight of the rest of the path (from B toward target) * @param inbound_opt B's inbound fees for traffic from A - * @param enableInboundFees whether to include BLIP-18 inbound fees + * @param blip18 bLIP-18 inbound fees settings * @param isSenderEdge true if A is the payment sender (no outbound fee charged) * @return (totalAmount, outboundFee) where totalAmount is what A needs to receive and * outboundFee is A's outbound fee (stored for next iteration's inbound fee capping) */ - private def addEdgeFeesWithInbound(edge: GraphEdge, prev: PaymentPathWeight, inbound_opt: Option[Relayer.InboundFees], enableInboundFees: Boolean, isSenderEdge: Boolean): (MilliSatoshi, MilliSatoshi) = { - if (!enableInboundFees) { + private def addEdgeFeesWithInbound(edge: GraphEdge, prev: PaymentPathWeight, inbound_opt: Option[Relayer.InboundFees], blip18: Blip18Params, isSenderEdge: Boolean): (MilliSatoshi, MilliSatoshi) = { + if (!blip18.enableInboundFees) { if (isSenderEdge) { (prev.amount, 0 msat) } else { @@ -558,16 +555,16 @@ object Graph { * @param wr ratios used to 'weight' edges when searching for the shortest path * @param includeLocalChannelCost if the path is for relaying and we need to include the cost of the local channel * @param graph the graph structure for back-edge lookups (used for inbound fees) - * @param enableInboundFees whether to include BLIP-18 inbound fees in weight calculation + * @param blip18 bLIP-18 inbound fees settings */ - def pathWeight(balances: BalancesEstimates, sender: PublicKey, path: Seq[GraphEdge], amount: MilliSatoshi, currentBlockHeight: BlockHeight, wr: WeightRatios[PaymentPathWeight], includeLocalChannelCost: Boolean, graph: GraphStructure.DirectedGraph, enableInboundFees: Boolean): PaymentPathWeight = { + def pathWeight(balances: BalancesEstimates, sender: PublicKey, path: Seq[GraphEdge], amount: MilliSatoshi, currentBlockHeight: BlockHeight, wr: WeightRatios[PaymentPathWeight], includeLocalChannelCost: Boolean, graph: GraphStructure.DirectedGraph, blip18: Blip18Params): PaymentPathWeight = { if (path.isEmpty) return PaymentPathWeight(amount) val targetNode = path.last.desc.b path.foldRight(PaymentPathWeight(amount)) { (edge, prev) => // Inbound fees never apply to the last edge of the path: the recipient doesn't relay the payment, so it // cannot charge relay fees, regardless of what its channel_update advertises. - val inboundFees_opt = if (enableInboundFees && edge.desc.b != targetNode) getInboundFees(graph, edge) else None - wr.addEdgeWeight(sender, edge, balances.get(edge), prev, currentBlockHeight, includeLocalChannelCost, inboundFees_opt, enableInboundFees) + val inboundFees_opt = if (blip18.enableInboundFees && edge.desc.b != targetNode) getInboundFees(graph, edge) else None + wr.addEdgeWeight(sender, edge, balances.get(edge), prev, currentBlockHeight, includeLocalChannelCost, inboundFees_opt, blip18) } } 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 cdac892813..3aec902e57 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 @@ -57,7 +57,8 @@ object RouteCalculation { } } - def validatePositiveInboundFees(route: Route, excludePositiveInboundFees: Boolean): Try[Route] = { + def validatePositiveInboundFees(route: Route, blip18: Blip18Params): Try[Route] = { + val excludePositiveInboundFees = blip18.enableInboundFees && blip18.excludePositiveInboundFees if (!excludePositiveInboundFees || route.hops.forall(hop => hop.params.inboundFees_opt.forall(i => i.feeBase <= 0.msat && i.feeProportionalMillionths <= 0))) { Success(route) } else { @@ -76,8 +77,8 @@ object RouteCalculation { val g = extraEdges.foldLeft(d.graphWithBalances.graph) { case (g: DirectedGraph, e: GraphEdge) => g.addEdge(e) } def finalizeAndReply(amount: MilliSatoshi, edges: Seq[GraphEdge], maxFee_opt: Option[MilliSatoshi]): Unit = { - val hops = Graph.enrichPathWithInboundFees(edges, g, fr.blip18InboundFees).map(e => ChannelHop(getEdgeRelayScid(d, localNodeId, e), e.desc.a, e.desc.b, e.params)) - val route = validatePositiveInboundFees(Route(amount, hops, None), fr.blip18InboundFees && fr.excludePositiveInboundFees).flatMap(validateMaxRouteFee(_, maxFee_opt)) + val hops = Graph.enrichPathWithInboundFees(edges, g, fr.blip18).map(e => ChannelHop(getEdgeRelayScid(d, localNodeId, e), e.desc.a, e.desc.b, e.params)) + val route = validatePositiveInboundFees(Route(amount, hops, None), fr.blip18).flatMap(validateMaxRouteFee(_, maxFee_opt)) route match { case Success(r) => fr.replyTo ! RouteResponse(r :: Nil) case Failure(f) => fr.replyTo ! PaymentRouteNotFound(f) @@ -208,9 +209,9 @@ object RouteCalculation { val tags = TagSet.Empty.withTag(Tags.MultiPart, r.allowMultiPart).withTag(Tags.Amount, Tags.amountBucket(amountToSend)) KamonExt.time(Metrics.FindRouteDuration.withTags(tags.withTag(Tags.NumberOfRoutes, routesToFind.toLong))) { val result = if (r.allowMultiPart) { - findMultiPartRoute(d.graphWithBalances, r.source, targetNodeId, amountToSend, maxFee, extraEdges, ignoredEdges, r.ignore.nodes, r.pendingPayments, r.routeParams, currentBlockHeight, r.blip18InboundFees, r.excludePositiveInboundFees) + findMultiPartRoute(d.graphWithBalances, r.source, targetNodeId, amountToSend, maxFee, extraEdges, ignoredEdges, r.ignore.nodes, r.pendingPayments, r.routeParams, currentBlockHeight, r.blip18) } else { - findRoute(d.graphWithBalances, r.source, targetNodeId, amountToSend, maxFee, routesToFind, extraEdges, ignoredEdges, r.ignore.nodes, r.routeParams, currentBlockHeight, r.blip18InboundFees, r.excludePositiveInboundFees) + findRoute(d.graphWithBalances, r.source, targetNodeId, amountToSend, maxFee, routesToFind, extraEdges, ignoredEdges, r.ignore.nodes, r.routeParams, currentBlockHeight, r.blip18) } result.map(routes => addFinalHop(r.target, routes)) match { case Success(routes) => @@ -246,7 +247,7 @@ object RouteCalculation { weight.length <= ROUTE_MAX_LENGTH && weight.cltv <= r.routeParams.boundaries.maxCltv } - val routes = Graph.routeBlindingPaths(d.graphWithBalances, r.source, r.target, r.amount, r.ignore.channels, r.ignore.nodes, r.pathsToFind, r.routeParams.heuristics, currentBlockHeight, boundaries, r.excludePositiveInboundFees, r.blip18InboundFees) + val routes = Graph.routeBlindingPaths(d.graphWithBalances, r.source, r.target, r.amount, r.ignore.channels, r.ignore.nodes, r.pathsToFind, r.routeParams.heuristics, currentBlockHeight, boundaries, r.blip18) if (routes.isEmpty) { r.replyTo ! PaymentRouteNotFound(RouteNotFound) } else { @@ -321,10 +322,9 @@ object RouteCalculation { ignoredVertices: Set[PublicKey] = Set.empty, routeParams: RouteParams, currentBlockHeight: BlockHeight, - blip18InboundFees: Boolean = false, - excludePositiveInboundFees: Boolean = false, + blip18: Blip18Params = Blip18Params.disabled, ): Try[Seq[Route]] = Try { - findRouteInternal(g, localNodeId, targetNodeId, amount, maxFee, numRoutes, extraEdges, ignoredEdges, ignoredVertices, routeParams, currentBlockHeight, excludePositiveInboundFees, blip18InboundFees) match { + findRouteInternal(g, localNodeId, targetNodeId, amount, maxFee, numRoutes, extraEdges, ignoredEdges, ignoredVertices, routeParams, currentBlockHeight, blip18) match { case Right(routes) => routes.map { route => Route(amount, route.path.map(graphEdgeToHop), None) } @@ -344,8 +344,7 @@ object RouteCalculation { ignoredVertices: Set[PublicKey] = Set.empty, routeParams: RouteParams, currentBlockHeight: BlockHeight, - excludePositiveInboundFees: Boolean, - enableInboundFees: Boolean = false): Either[RouterException, Seq[WeightedPath[PaymentPathWeight]]] = { + blip18: Blip18Params = Blip18Params.disabled): Either[RouterException, Seq[WeightedPath[PaymentPathWeight]]] = { require(amount > 0.msat, "route amount must be strictly positive") @@ -359,7 +358,7 @@ object RouteCalculation { val boundaries: PaymentPathWeight => Boolean = { weight => feeOk(weight.amount - amount) && lengthOk(weight.length) && cltvOk(weight.cltv) } - val foundRoutes: Seq[WeightedPath[PaymentPathWeight]] = Graph.yenKshortestPaths(g, localNodeId, targetNodeId, amount, ignoredEdges, ignoredVertices, extraEdges, numRoutes, routeParams.heuristics, currentBlockHeight, boundaries, routeParams.includeLocalChannelCost, excludePositiveInboundFees, enableInboundFees) + val foundRoutes: Seq[WeightedPath[PaymentPathWeight]] = Graph.yenKshortestPaths(g, localNodeId, targetNodeId, amount, ignoredEdges, ignoredVertices, extraEdges, numRoutes, routeParams.heuristics, currentBlockHeight, boundaries, routeParams.includeLocalChannelCost, blip18) if (foundRoutes.nonEmpty) { val (directRoutes, indirectRoutes) = foundRoutes.partition(_.path.length == 1) val routes = if (routeParams.randomize) { @@ -376,7 +375,7 @@ object RouteCalculation { maxCltv = DEFAULT_ROUTE_MAX_CLTV, ) ) - findRouteInternal(g, localNodeId, targetNodeId, amount, maxFee, numRoutes, extraEdges, ignoredEdges, ignoredVertices, relaxedRouteParams, currentBlockHeight, excludePositiveInboundFees, enableInboundFees) + findRouteInternal(g, localNodeId, targetNodeId, amount, maxFee, numRoutes, extraEdges, ignoredEdges, ignoredVertices, relaxedRouteParams, currentBlockHeight, blip18) } else { Left(RouteNotFound) } @@ -408,9 +407,8 @@ object RouteCalculation { pendingHtlcs: Seq[Route] = Nil, routeParams: RouteParams, currentBlockHeight: BlockHeight, - blip18InboundFees: Boolean = false, - excludePositiveInboundFees: Boolean = false): Try[Seq[Route]] = Try { - findMultiPartRouteInternal(g, localNodeId, targetNodeId, amount, maxFee, extraEdges, ignoredEdges, ignoredVertices, pendingHtlcs, routeParams, currentBlockHeight, blip18InboundFees, excludePositiveInboundFees) match { + blip18: Blip18Params = Blip18Params.disabled): Try[Seq[Route]] = Try { + findMultiPartRouteInternal(g, localNodeId, targetNodeId, amount, maxFee, extraEdges, ignoredEdges, ignoredVertices, pendingHtlcs, routeParams, currentBlockHeight, blip18) match { case Right(routes) => routes case Left(ex) => return Failure(ex) } @@ -427,8 +425,7 @@ object RouteCalculation { pendingHtlcs: Seq[Route] = Nil, routeParams: RouteParams, currentBlockHeight: BlockHeight, - blip18InboundFees: Boolean, - excludePositiveInboundFees: Boolean, + blip18: Blip18Params = Blip18Params.disabled, 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. @@ -447,7 +444,7 @@ object RouteCalculation { val minPartAmount = routeParams.mpp.minPartAmount.max(amount / numRoutes).min(amount) 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, excludePositiveInboundFees, blip18InboundFees) match { + findRouteInternal(g, localNodeId, targetNodeId, routeParams1.mpp.minPartAmount, maxFee, routeParams1.mpp.maxParts, extraEdges, ignoredEdges, ignoredVertices, routeParams1, currentBlockHeight, blip18) match { 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(paths: _*), initializeUsedCapacity(pendingHtlcs), routeParams1, g.balances, now) match { 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 4b5ba248d8..7ed22ddcd7 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 @@ -400,6 +400,19 @@ object Router { require(channelQueryChunkSize <= Sync.MAXIMUM_CHUNK_SIZE, "channel query chunk size exceeds the size of a lightning message") } + /** + * bLIP-18 inbound routing fees settings. + * + * @param enableInboundFees include bLIP-18 inbound fees when computing routes and relay fees. + * @param excludePositiveInboundFees exclude channels advertising positive inbound fees from path-finding, which helps + * avoid `FeeInsufficient` errors and makes routing more reliable. + */ + case class Blip18Params(enableInboundFees: Boolean, excludePositiveInboundFees: Boolean = false) + + object Blip18Params { + val disabled: Blip18Params = Blip18Params(enableInboundFees = false, excludePositiveInboundFees = false) + } + case class RouterConf(watchSpentWindow: FiniteDuration, channelSpentSpliceDelay: Int, channelExcludeDuration: FiniteDuration, @@ -408,8 +421,7 @@ object Router { pathFindingExperimentConf: PathFindingExperimentConf, messageRouteParams: MessageRouteParams, balanceEstimateHalfLife: FiniteDuration, - blip18InboundFees: Boolean, - excludePositiveInboundFees: Boolean) + blip18: Blip18Params) // @formatter:off case class ChannelDesc private(shortChannelId: ShortChannelId, a: PublicKey, b: PublicKey){ @@ -654,8 +666,7 @@ object Router { allowMultiPart: Boolean = false, pendingPayments: Seq[Route] = Nil, paymentContext: Option[PaymentContext] = None, - blip18InboundFees: Boolean = false, - excludePositiveInboundFees: Boolean = false) + blip18: Blip18Params = Blip18Params.disabled) case class BlindedRouteRequest(replyTo: typed.ActorRef[PaymentRouteResponse], source: PublicKey, @@ -664,15 +675,13 @@ object Router { routeParams: RouteParams, pathsToFind: Int, ignore: Ignore = Ignore.empty, - blip18InboundFees: Boolean, - excludePositiveInboundFees: Boolean) + blip18: Blip18Params = Blip18Params.disabled) case class FinalizeRoute(replyTo: typed.ActorRef[PaymentRouteResponse], route: PredefinedRoute, extraEdges: Seq[ExtraEdge] = Nil, paymentContext: Option[PaymentContext] = None, - blip18InboundFees: Boolean = false, - excludePositiveInboundFees: Boolean = false) + blip18: Blip18Params = Blip18Params.disabled) sealed trait PostmanRequest @@ -746,7 +755,7 @@ 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 { override def isEmpty = nodes.isEmpty override def targetNodeId: PublicKey = nodes.last diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala index 1ac62b49af..49b6cba441 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala @@ -76,7 +76,7 @@ class EclairImplSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with I val defaultOfferHandler = TestProbe() val peerScorer = TestProbe() val kit = Kit( - TestConstants.Alice.nodeParams.copy(routerConf = TestConstants.Alice.nodeParams.routerConf.copy(blip18InboundFees = true)), + TestConstants.Alice.nodeParams.copy(routerConf = TestConstants.Alice.nodeParams.routerConf.copy(blip18 = Router.Blip18Params(enableInboundFees = true, excludePositiveInboundFees = false))), system, watcher.ref, paymentHandler.ref, diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala index 998864375d..4d088e5beb 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala @@ -243,8 +243,7 @@ object TestConstants { experimentPercentage = 100))), messageRouteParams = MessageRouteParams(8, MessageWeightRatios(0.7, 0.1, 0.2)), balanceEstimateHalfLife = 1 day, - blip18InboundFees = false, - excludePositiveInboundFees = false, + blip18 = Blip18Params.disabled, ), socksProxy_opt = None, maxPaymentAttempts = 5, @@ -467,8 +466,7 @@ object TestConstants { experimentPercentage = 100))), messageRouteParams = MessageRouteParams(9, MessageWeightRatios(0.5, 0.2, 0.3)), balanceEstimateHalfLife = 1 day, - blip18InboundFees = false, - excludePositiveInboundFees = false, + blip18 = Blip18Params.disabled, ), socksProxy_opt = None, maxPaymentAttempts = 5, diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/ChannelRelayerSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/ChannelRelayerSpec.scala index bcc07b90b8..58d8550912 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/ChannelRelayerSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/ChannelRelayerSpec.scala @@ -87,7 +87,7 @@ class ChannelRelayerSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("a .modify(_.peerWakeUpConfig.enabled).setToIf(test.tags.contains(wakeUpEnabled))(true) .modify(_.peerWakeUpConfig.timeout).setToIf(test.tags.contains(wakeUpTimeout))(100 millis) .modify(_.features.activated).usingIf(test.tags.contains(onTheFlyFunding))(_ + (Features.OnTheFlyFunding -> FeatureSupport.Optional)) - .modify(_.routerConf.blip18InboundFees).setToIf(test.tags.contains(blip18InboundFees))(true) + .modify(_.routerConf.blip18.enableInboundFees).setToIf(test.tags.contains(blip18InboundFees))(true) val register = TestProbe[Any]("register") val reputationRecorder = TestProbe[ReputationRecorder.Command]("reputation-recorder") val channelRelayer = testKit.spawn(ChannelRelayer.apply(nodeParams, register.ref.toClassic, if (test.tags.contains(ignoreReputation)) None else Some(reputationRecorder.ref))) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/router/Blip18FinalizeRouteSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/router/Blip18FinalizeRouteSpec.scala index 0df9e00eee..e320937424 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/router/Blip18FinalizeRouteSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/router/Blip18FinalizeRouteSpec.scala @@ -81,7 +81,7 @@ class Blip18FinalizeRouteSpec extends TestKitBaseClass with AnyFunSuiteLike with val route = PredefinedNodeRoute(100_000 msat, Seq(a, b, c, d)) val probe = TestProbe() val replyTo = probe.ref.toTyped[Router.PaymentRouteResponse] - val fr = Router.FinalizeRoute(replyTo, route, blip18InboundFees = true, excludePositiveInboundFees = false) + val fr = Router.FinalizeRoute(replyTo, route, blip18 = Blip18Params(enableInboundFees = true)) RouteCalculation.finalizeRoute(data, a, fr) @@ -130,7 +130,7 @@ class Blip18FinalizeRouteSpec extends TestKitBaseClass with AnyFunSuiteLike with val route = PredefinedNodeRoute(100_000 msat, Seq(a, b, c, d)) val probe = TestProbe() val replyTo = probe.ref.toTyped[Router.PaymentRouteResponse] - val fr = Router.FinalizeRoute(replyTo, route, blip18InboundFees = false, excludePositiveInboundFees = false) + val fr = Router.FinalizeRoute(replyTo, route, blip18 = Blip18Params.disabled) RouteCalculation.finalizeRoute(data, a, fr) @@ -179,7 +179,7 @@ class Blip18FinalizeRouteSpec extends TestKitBaseClass with AnyFunSuiteLike with val route = PredefinedChannelRoute(100_000 msat, e, Seq(ShortChannelId(10L), ShortChannelId(11L), ShortChannelId(12L), ShortChannelId(13L))) val probe = TestProbe() val replyTo = probe.ref.toTyped[Router.PaymentRouteResponse] - val fr = Router.FinalizeRoute(replyTo, route, blip18InboundFees = true, excludePositiveInboundFees = false) + val fr = Router.FinalizeRoute(replyTo, route, blip18 = Blip18Params(enableInboundFees = true)) RouteCalculation.finalizeRoute(data, a, fr) @@ -237,7 +237,7 @@ class Blip18FinalizeRouteSpec extends TestKitBaseClass with AnyFunSuiteLike with val route = PredefinedChannelRoute(100_000 msat, e, Seq(ShortChannelId(10L), ShortChannelId(11L), ShortChannelId(12L), ShortChannelId(13L))) val probe = TestProbe() val replyTo = probe.ref.toTyped[Router.PaymentRouteResponse] - val fr = Router.FinalizeRoute(replyTo, route, blip18InboundFees = false, excludePositiveInboundFees = false) + val fr = Router.FinalizeRoute(replyTo, route, blip18 = Blip18Params.disabled) RouteCalculation.finalizeRoute(data, a, fr) @@ -282,7 +282,7 @@ class Blip18FinalizeRouteSpec extends TestKitBaseClass with AnyFunSuiteLike with val route = PredefinedNodeRoute(100_000 msat, Seq(a, b, c)) val probe = TestProbe() val replyTo = probe.ref.toTyped[Router.PaymentRouteResponse] - val fr = Router.FinalizeRoute(replyTo, route, blip18InboundFees = true, excludePositiveInboundFees = true) + val fr = Router.FinalizeRoute(replyTo, route, blip18 = Blip18Params(enableInboundFees = true, excludePositiveInboundFees = true)) RouteCalculation.finalizeRoute(data, a, fr) 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 700456c924..9402593476 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 @@ -52,7 +52,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(10L, a, b, minHtlc = 2 msat, feeBase = 0 msat, feeProportionalMillionth = 120, inboundFeeBase_opt = Some(0.msat), inboundFeeProportionalMillionth_opt = Some(-71)), )), 1 day) - 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), blip18InboundFees = true, excludePositiveInboundFees = true) + 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), blip18 = Blip18Params(enableInboundFees = true, excludePositiveInboundFees = true)) assert(route.channelFee(true) == 1200.msat) } @@ -73,7 +73,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(13L, e, d, minHtlc = 2 msat, inboundFeeBase_opt = Some(2000 msat), inboundFeeProportionalMillionth_opt = Some(80_000)), )), 1 day) - val Success(route :: Nil) = findRoute(g, a, e, 100_000 msat, 100_000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true, excludePositiveInboundFees = false) + val Success(route :: Nil) = findRoute(g, a, e, 100_000 msat, 100_000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route.channelFee(false) == 15_302.msat) @@ -126,7 +126,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(13L, e, d, minHtlc = 2 msat, inboundFeeBase_opt = Some(2000 msat), inboundFeeProportionalMillionth_opt = Some(80_000)), )), 1 day) - val Success(route :: Nil) = findRoute(g, a, e, 100_000 msat, 100_000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = false, excludePositiveInboundFees = false) + val Success(route :: Nil) = findRoute(g, a, e, 100_000 msat, 100_000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params.disabled) assert(route.channelFee(false) == 32_197.msat) @@ -184,7 +184,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(31L, e, f, minHtlc = 2 msat), )), 1 day) - val Success(route :: Nil) = findRoute(g, a, e, 100_000 msat, 100_000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true, excludePositiveInboundFees = false) + val Success(route :: Nil) = findRoute(g, a, e, 100_000 msat, 100_000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) // With BLIP-18, path 1 is cheaper thanks to negative inbound fee discounts assert(route.hops.length == 4) @@ -242,7 +242,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(31L, e, f, minHtlc = 2 msat), )), 1 day) - val Success(route :: Nil) = findRoute(g, a, e, 100_000 msat, 100_000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = false, excludePositiveInboundFees = false) + val Success(route :: Nil) = findRoute(g, a, e, 100_000 msat, 100_000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params.disabled) // Without BLIP-18, path 2 is cheaper based on outbound fees alone assert(route.hops.length == 2) @@ -286,7 +286,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(13L, e, d, minHtlc = 2 msat, inboundFeeBase_opt = Some(2000 msat), inboundFeeProportionalMillionth_opt = Some(80_000)), )), 1 day) - val Success(route :: Nil) = findMultiPartRoute(g, a, e, 100_000 msat, 100_000 msat, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true, excludePositiveInboundFees = false) + val Success(route :: Nil) = findMultiPartRoute(g, a, e, 100_000 msat, 100_000 msat, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route.channelFee(false) == 15_302.msat) @@ -339,7 +339,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(13L, e, d, minHtlc = 2 msat, inboundFeeBase_opt = Some(2000 msat), inboundFeeProportionalMillionth_opt = Some(80_000)), )), 1 day) - val Success(route :: Nil) = findMultiPartRoute(g, a, e, 100_000 msat, 100_000 msat, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = false, excludePositiveInboundFees = false) + val Success(route :: Nil) = findMultiPartRoute(g, a, e, 100_000 msat, 100_000 msat, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params.disabled) assert(route.channelFee(false) == 32_197.msat) @@ -397,7 +397,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(51L, e, f, minHtlc = 2 msat), )), 1 day) - val Success(route :: Nil) = findRoute(g, a, e, 100_000 msat, 100_000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true, excludePositiveInboundFees = false) + val Success(route :: Nil) = findRoute(g, a, e, 100_000 msat, 100_000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) // With BLIP-18, path 2 is cheaper because path 1's positive inbound fees make it more expensive assert(route.hops.length == 2) @@ -443,7 +443,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(51L, e, f, minHtlc = 2 msat), )), 1 day) - val Success(route :: Nil) = findRoute(g, a, e, 100_000 msat, 100_000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = false, excludePositiveInboundFees = false) + val Success(route :: Nil) = findRoute(g, a, e, 100_000 msat, 100_000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params.disabled) // Without BLIP-18, path 1 is cheaper based on outbound fees alone assert(route.hops.length == 4) @@ -504,7 +504,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution val Success(route :: Nil) = findRoute(g, a, c, 100_000 msat, 100_000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), - blip18InboundFees = true, excludePositiveInboundFees = false) + blip18 = Blip18Params(enableInboundFees = true)) // Path 2 (via d, channels 20 and 21) wins by 1 msat assert(route.hops.length == 2) @@ -553,7 +553,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution val Success(route :: Nil) = findRoute(g, a, c, 100_000 msat, 100_000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), - blip18InboundFees = true, excludePositiveInboundFees = false) + blip18 = Blip18Params(enableInboundFees = true)) // Path 2 (via d, channels 20 and 21) wins by 1 msat assert(route.hops.length == 2) @@ -592,7 +592,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(4L, d, e, 1 msat, 10, cltvDelta = CltvExpiryDelta(1)) )), 1 day) - val res = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true, excludePositiveInboundFees = true) + val res = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true, excludePositiveInboundFees = true)) assert(res == Failure(RouteNotFound)) } { @@ -604,7 +604,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(4L, d, e, 1 msat, 10, cltvDelta = CltvExpiryDelta(1)) )), 1 day) - val res = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true, excludePositiveInboundFees = true) + val res = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true, excludePositiveInboundFees = true)) assert(res == Failure(RouteNotFound)) } { @@ -616,7 +616,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(4L, d, e, 1 msat, 10, cltvDelta = CltvExpiryDelta(1)) )), 1 day) - val res = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true, excludePositiveInboundFees = true) + val res = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true, excludePositiveInboundFees = true)) assert(res == Failure(RouteNotFound)) } { @@ -628,7 +628,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(4L, d, e, 1 msat, 10, cltvDelta = CltvExpiryDelta(1)) )), 1 day) - val res = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true, excludePositiveInboundFees = true) + val res = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true, excludePositiveInboundFees = true)) assert(res == Failure(RouteNotFound)) } { @@ -640,7 +640,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(4L, d, e, 1 msat, 10, cltvDelta = CltvExpiryDelta(1)) )), 1 day) - val res = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true, excludePositiveInboundFees = true) + val res = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true, excludePositiveInboundFees = true)) assert(res == Failure(RouteNotFound)) } } @@ -656,7 +656,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(4L, e, d, 1 msat, 10, cltvDelta = CltvExpiryDelta(1), inboundFeeBase_opt = Some(1 msat), inboundFeeProportionalMillionth_opt = Some(10)) )), 1 day) - val Success(route :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true, excludePositiveInboundFees = true) + val Success(route :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true, excludePositiveInboundFees = true)) assert(route2Ids(route) == 1 :: 2 :: 3 :: 4 :: Nil) } @@ -671,7 +671,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(4L, d, e, 1 msat, 10, cltvDelta = CltvExpiryDelta(1)) )), 1 day) - val Success(route :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = false, excludePositiveInboundFees = true) + val Success(route :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = false, excludePositiveInboundFees = true)) assert(route2Ids(route) == 1 :: 2 :: 3 :: 4 :: Nil) } @@ -685,7 +685,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(4L, d, e, 1 msat, 10, cltvDelta = CltvExpiryDelta(1)) )), 1 day) - val Success(route :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(route :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route2Ids(route) == 1 :: 2 :: 3 :: 4 :: Nil) } @@ -706,7 +706,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(4L, d, e, 10 msat, 10, cltvDelta = CltvExpiryDelta(1)) )), 1 day) - val Success(route :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, maxFee, numRoutes = 1, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(route :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, maxFee, numRoutes = 1, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route2Ids(route) == 1 :: 2 :: 3 :: 4 :: Nil) } @@ -749,8 +749,8 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(6L, f, d, feeBase = 1 msat, feeProportionalMillionth = 100, minHtlc = 0 msat) )), 1 day) - val Success(route :: Nil) = findRoute(graph, a, d, amount, maxFee = 7 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) - val weightedPath = Graph.pathWeight(graph.balances, a, route2Edges(route), amount, BlockHeight(0), NO_WEIGHT_RATIOS, includeLocalChannelCost = false, graph.graph, enableInboundFees = false) + val Success(route :: Nil) = findRoute(graph, a, d, amount, maxFee = 7 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) + val weightedPath = Graph.pathWeight(graph.balances, a, route2Edges(route), amount, BlockHeight(0), NO_WEIGHT_RATIOS, includeLocalChannelCost = false, graph.graph, blip18 = Blip18Params.disabled) assert(route2Ids(route) == 4 :: 5 :: 6 :: Nil) assert(weightedPath.length == 3) assert(weightedPath.amount == expectedCost) @@ -760,7 +760,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution val graph2 = graph.addEdge(makeEdge(5L, e, f, feeBase = 1 msat, feeProportionalMillionth = 400, minHtlc = 0 msat, capacity = 10 sat)) val graph3 = graph.addEdge(makeEdge(5L, e, f, feeBase = 1 msat, feeProportionalMillionth = 400, minHtlc = 0 msat, balance_opt = Some(10001 msat))) for (g <- Seq(graph1, graph2, graph3)) { - val Success(route1 :: Nil) = findRoute(g, a, d, amount, maxFee = 10 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(route1 :: Nil) = findRoute(g, a, d, amount, maxFee = 10 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route2Ids(route1) == 1 :: 2 :: 3 :: Nil) } } @@ -774,7 +774,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(5L, d, e, 5 msat, 0) // d -> e )), 1 day) - val Success(route :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(route :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route2Ids(route) == 2 :: 5 :: Nil) } @@ -786,11 +786,11 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(4L, d, e, 0 msat, 0) )), 1 day) - val Success(route1 :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(route1 :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route2Ids(route1) == 1 :: 2 :: 3 :: 4 :: Nil) val graphWithRemovedEdge = g.disableEdge(ChannelDesc(ShortChannelId(3L), c, d)) - val route2 = findRoute(graphWithRemovedEdge, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val route2 = findRoute(graphWithRemovedEdge, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route2 == Failure(RouteNotFound)) } @@ -809,7 +809,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(4L, f, h, 50 msat, 0) // more expensive but fee will be ignored since f is the payer )), 1 day) - val Success(route :: Nil) = findRoute(graph, f, i, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(route :: Nil) = findRoute(graph, f, i, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route2Ids(route) == 4 :: 3 :: Nil) } @@ -828,7 +828,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(3L, h, i, 0 msat, 0) )), 1 day) - val Success(route1 :: route2 :: Nil) = findRoute(graph, f, i, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 2, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(route1 :: route2 :: Nil) = findRoute(graph, f, i, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 2, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route2Ids(route1) == 4 :: Nil) assert(route2Ids(route2) == 1 :: 2 :: 3 :: Nil) } @@ -848,7 +848,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(3L, h, i, 1 msat, 0) )), 1 day) - val Success(route :: Nil) = findRoute(graph, f, i, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(route :: Nil) = findRoute(graph, f, i, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route2Ids(route) == 1 :: 2 :: 3 :: Nil) } @@ -867,7 +867,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(3L, h, i, 1 msat, 0) )), 1 day) - val route = findRoute(graph, f, i, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val route = findRoute(graph, f, i, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route == Failure(RouteNotFound)) } @@ -886,7 +886,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(3L, h, i, 0 msat, 0) )), 1 day) - val Success(route :: Nil) = findRoute(graph, f, i, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(route :: Nil) = findRoute(graph, f, i, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route2Ids(route) == 1 :: 6 :: 3 :: Nil) } @@ -905,7 +905,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(3L, h, i, 0 msat, 0) )), 1 day) - val Success(route :: Nil) = findRoute(graph, f, i, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(route :: Nil) = findRoute(graph, f, i, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route2Ids(route) == 1 :: 2 :: 3 :: Nil) } @@ -918,7 +918,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(5L, b, e, 10 msat, 10) )), 1 day) - val Success(route :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(route :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route2Ids(route) == 1 :: 2 :: 3 :: 4 :: Nil) } @@ -928,7 +928,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(4L, d, e, 0 msat, 0) )), 1 day) - val route = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val route = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route == Failure(RouteNotFound)) } @@ -939,7 +939,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(4L, d, e, 0 msat, 0) )), 1 day) - val route = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val route = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route == Failure(RouteNotFound)) } @@ -956,8 +956,8 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(4L, c, d, 0 msat, 0) )).addOrUpdateVertex(annA).addOrUpdateVertex(annE), 1 day) - assert(findRoute(g, a, d, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) == Failure(RouteNotFound)) - assert(findRoute(g, b, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) == Failure(RouteNotFound)) + assert(findRoute(g, a, d, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) == Failure(RouteNotFound)) + assert(findRoute(g, b, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) == Failure(RouteNotFound)) } test("route not found (amount too high OR too low)") { @@ -979,8 +979,8 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution val g = GraphWithBalanceEstimates(DirectedGraph(edgesHi), 1 day) val g1 = GraphWithBalanceEstimates(DirectedGraph(edgesLo), 1 day) - assert(findRoute(g, a, d, highAmount, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) == Failure(RouteNotFound)) - assert(findRoute(g1, a, d, lowAmount, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) == Failure(RouteNotFound)) + assert(findRoute(g, a, d, highAmount, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) == Failure(RouteNotFound)) + assert(findRoute(g1, a, d, lowAmount, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) == Failure(RouteNotFound)) } test("route not found (balance too low)") { @@ -989,7 +989,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(2L, b, c, 1 msat, 2, minHtlc = 10000 msat), makeEdge(3L, c, d, 1 msat, 2, minHtlc = 10000 msat) )), 1 day) - assert(findRoute(g, a, d, 15000 msat, 100 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true).isSuccess) + assert(findRoute(g, a, d, 15000 msat, 100 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)).isSuccess) // not enough balance on the last edge val g1 = GraphWithBalanceEstimates(DirectedGraph(List( @@ -1009,7 +1009,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(2L, b, c, 1 msat, 2, minHtlc = 10000 msat), makeEdge(3L, c, d, 1 msat, 2, minHtlc = 10000 msat) )), 1 day) - Seq(g1, g2, g3).foreach(g => assert(findRoute(g, a, d, 15000 msat, 100 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) == Failure(RouteNotFound))) + Seq(g1, g2, g3).foreach(g => assert(findRoute(g, a, d, 15000 msat, 100 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) == Failure(RouteNotFound))) } test("route to self") { @@ -1019,7 +1019,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(3L, c, d, 0 msat, 0) )), 1 day) - val route = findRoute(g, a, a, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val route = findRoute(g, a, a, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route == Failure(CannotRouteToSelf)) } @@ -1031,7 +1031,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(4L, d, e, 0 msat, 0) )), 1 day) - val Success(route :: Nil) = findRoute(g, a, b, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(route :: Nil) = findRoute(g, a, b, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route2Ids(route) == 1 :: Nil) } @@ -1044,10 +1044,10 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(4L, d, e, 0 msat, 0) )), 1 day) - val Success(route1 :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(route1 :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route2Ids(route1) == 1 :: 2 :: 3 :: 4 :: Nil) - val route2 = findRoute(g, e, a, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val route2 = findRoute(g, e, a, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route2 == Failure(RouteNotFound)) } @@ -1075,7 +1075,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution ) val g = GraphWithBalanceEstimates(DirectedGraph(edges), 1 day) - val Success(route :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(route :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route.hops == channelHopFromUpdate(a, b, uab) :: channelHopFromUpdate(b, c, ubc) :: channelHopFromUpdate(c, d, ucd) :: channelHopFromUpdate(d, e, ude) :: Nil) } @@ -1087,7 +1087,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(4L, d, e, 0 msat, 0) )), 1 day) - val route1 = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, ignoredEdges = Set(ChannelDesc(ShortChannelId(3L), c, d)), routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val route1 = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, ignoredEdges = Set(ChannelDesc(ShortChannelId(3L), c, d)), routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route1 == Failure(RouteNotFound)) // verify that we left the graph untouched @@ -1096,7 +1096,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution assert(g.graph.containsVertex(d)) // make sure we can find a route if without the blacklist - val Success(route2 :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(route2 :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route2Ids(route2) == 1 :: 2 :: 3 :: 4 :: Nil) } @@ -1107,12 +1107,12 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(3L, c, d, 10 msat, 10) )), 1 day) - val route = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val route = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route == Failure(RouteNotFound)) // now we add the missing edge to reach the destination val extraGraphEdges = Set(makeEdge(4L, d, e, 5 msat, 5)) - val Success(route1 :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, extraEdges = extraGraphEdges, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(route1 :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, extraEdges = extraGraphEdges, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route2Ids(route1) == 1 :: 2 :: 3 :: 4 :: Nil) } @@ -1122,12 +1122,12 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(3L, c, d, 10 msat, 10) )), 1 day) - val route = findRoute(g, a, d, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val route = findRoute(g, a, d, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route == Failure(RouteNotFound)) // now we add the missing starting edge val extraGraphEdges = Set(makeEdge(1L, a, b, 5 msat, 5)) - val Success(route1 :: Nil) = findRoute(g, a, d, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, extraEdges = extraGraphEdges, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(route1 :: Nil) = findRoute(g, a, d, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, extraEdges = extraGraphEdges, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route2Ids(route1) == 1 :: 2 :: 3 :: Nil) } @@ -1139,12 +1139,12 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(4L, d, e, 10 msat, 10) )), 1 day) - val Success(route1 :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(route1 :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route2Ids(route1) == 1 :: 2 :: 3 :: 4 :: Nil) assert(route1.hops(1).params.relayFees.feeBase == 10.msat) val extraGraphEdges = Set(makeEdge(2L, b, c, 5 msat, 5)) - val Success(route2 :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, extraEdges = extraGraphEdges, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(route2 :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, extraEdges = extraGraphEdges, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route2Ids(route2) == 1 :: 2 :: 3 :: 4 :: Nil) assert(route2.hops(1).params.relayFees.feeBase == 5.msat) } @@ -1202,10 +1202,10 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution val g = GraphWithBalanceEstimates(DirectedGraph(edges), 1 day) - assert(findRoute(g, nodes(0), nodes(18), DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true).map(r => route2Ids(r.head)) == Success(0 until 18)) - assert(findRoute(g, nodes(0), nodes(19), DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true).map(r => route2Ids(r.head)) == Success(0 until 19)) - assert(findRoute(g, nodes(0), nodes(20), DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true).map(r => route2Ids(r.head)) == Success(0 until 20)) - assert(findRoute(g, nodes(0), nodes(21), DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) == Failure(RouteNotFound)) + assert(findRoute(g, nodes(0), nodes(18), DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)).map(r => route2Ids(r.head)) == Success(0 until 18)) + assert(findRoute(g, nodes(0), nodes(19), DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)).map(r => route2Ids(r.head)) == Success(0 until 19)) + assert(findRoute(g, nodes(0), nodes(20), DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)).map(r => route2Ids(r.head)) == Success(0 until 20)) + assert(findRoute(g, nodes(0), nodes(21), DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) == Failure(RouteNotFound)) } test("ignore cheaper route when it has more than 20 hops") { @@ -1220,7 +1220,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution val g = GraphWithBalanceEstimates(DirectedGraph(expensiveShortEdge :: edges), 1 day) - val Success(route :: Nil) = findRoute(g, nodes(0), nodes(49), DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(route :: Nil) = findRoute(g, nodes(0), nodes(49), DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route2Ids(route) == 0 :: 1 :: 99 :: 48 :: Nil) } @@ -1235,7 +1235,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(6, f, d, feeBase = 5 msat, 0, minHtlc = 0 msat, maxHtlc = None, CltvExpiryDelta(9)) )), 1 day) - val Success(route :: Nil) = findRoute(g, a, d, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.modify(_.boundaries.maxCltv).setTo(CltvExpiryDelta(28)), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(route :: Nil) = findRoute(g, a, d, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.modify(_.boundaries.maxCltv).setTo(CltvExpiryDelta(28)), currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route2Ids(route) == 4 :: 5 :: 6 :: Nil) } @@ -1250,7 +1250,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(6, b, f, feeBase = 5 msat, 0, minHtlc = 0 msat, maxHtlc = None, CltvExpiryDelta(9)) )), 1 day) - val Success(route :: Nil) = findRoute(g, a, f, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.modify(_.boundaries.maxRouteLength).setTo(3), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(route :: Nil) = findRoute(g, a, f, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.modify(_.boundaries.maxRouteLength).setTo(3), currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route2Ids(route) == 1 :: 6 :: Nil) } @@ -1263,7 +1263,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(5L, d, e, 10 msat, 10) )), 1 day) - val Success(route :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(route :: Nil) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route2Ids(route) == 1 :: 2 :: 4 :: 5 :: Nil) } @@ -1279,7 +1279,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(5L, e, d, 0 msat, 0) // e -> d )), 1 day) - val Success(route :: Nil) = findRoute(g, a, d, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(route :: Nil) = findRoute(g, a, d, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route2Ids(route) == 1 :: 3 :: 5 :: Nil) } @@ -1312,7 +1312,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(7L, c, f, 1 msat, 0, balance_opt = Some(DEFAULT_AMOUNT_MSAT)) )), 1 day) - val fourShortestPaths = Graph.yenKshortestPaths(g1, d, f, DEFAULT_AMOUNT_MSAT, Set.empty, Set.empty, Set.empty, pathsToFind = 4, NO_WEIGHT_RATIOS, BlockHeight(0), noopBoundaries, includeLocalChannelCost = false, blip18InboundFees = true) + val fourShortestPaths = Graph.yenKshortestPaths(g1, d, f, DEFAULT_AMOUNT_MSAT, Set.empty, Set.empty, Set.empty, pathsToFind = 4, NO_WEIGHT_RATIOS, BlockHeight(0), noopBoundaries, includeLocalChannelCost = false, blip18 = Blip18Params(enableInboundFees = true)) assert(fourShortestPaths.size == 4) assert(hops2Ids(fourShortestPaths(0).path.map(graphEdgeToHop)) == 2 :: 5 :: Nil) // D -> E -> F assert(hops2Ids(fourShortestPaths(1).path.map(graphEdgeToHop)) == 1 :: 3 :: 5 :: Nil) // D -> A -> E -> F @@ -1321,7 +1321,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution // Update balance D -> A to evict the last path (balance too low) val g2 = g1.addEdge(makeEdge(1L, d, a, 1 msat, 0, balance_opt = Some(DEFAULT_AMOUNT_MSAT + 3.msat))) - val threeShortestPaths = Graph.yenKshortestPaths(g2, d, f, DEFAULT_AMOUNT_MSAT, Set.empty, Set.empty, Set.empty, pathsToFind = 4, NO_WEIGHT_RATIOS, BlockHeight(0), noopBoundaries, includeLocalChannelCost = false, blip18InboundFees = true) + val threeShortestPaths = Graph.yenKshortestPaths(g2, d, f, DEFAULT_AMOUNT_MSAT, Set.empty, Set.empty, Set.empty, pathsToFind = 4, NO_WEIGHT_RATIOS, BlockHeight(0), noopBoundaries, includeLocalChannelCost = false, blip18 = Blip18Params(enableInboundFees = true)) assert(threeShortestPaths.size == 3) assert(hops2Ids(threeShortestPaths(0).path.map(graphEdgeToHop)) == 2 :: 5 :: Nil) // D -> E -> F assert(hops2Ids(threeShortestPaths(1).path.map(graphEdgeToHop)) == 1 :: 3 :: 5 :: Nil) // D -> A -> E -> F @@ -1350,7 +1350,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(90L, g, h, 2 msat, 0) )), 1 day) - val twoShortestPaths = Graph.yenKshortestPaths(graph, c, h, DEFAULT_AMOUNT_MSAT, Set.empty, Set.empty, Set.empty, pathsToFind = 2, NO_WEIGHT_RATIOS, BlockHeight(0), noopBoundaries, includeLocalChannelCost = false, blip18InboundFees = true) + val twoShortestPaths = Graph.yenKshortestPaths(graph, c, h, DEFAULT_AMOUNT_MSAT, Set.empty, Set.empty, Set.empty, pathsToFind = 2, NO_WEIGHT_RATIOS, BlockHeight(0), noopBoundaries, includeLocalChannelCost = false, blip18 = Blip18Params(enableInboundFees = true)) assert(twoShortestPaths.size == 2) val shortest = twoShortestPaths(0) @@ -1381,7 +1381,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution )), 1 day) // we ask for 3 shortest paths but only 2 can be found - val foundPaths = Graph.yenKshortestPaths(graph, a, f, DEFAULT_AMOUNT_MSAT, Set.empty, Set.empty, Set.empty, pathsToFind = 3, NO_WEIGHT_RATIOS, BlockHeight(0), noopBoundaries, includeLocalChannelCost = false, blip18InboundFees = true) + val foundPaths = Graph.yenKshortestPaths(graph, a, f, DEFAULT_AMOUNT_MSAT, Set.empty, Set.empty, Set.empty, pathsToFind = 3, NO_WEIGHT_RATIOS, BlockHeight(0), noopBoundaries, includeLocalChannelCost = false, blip18 = Blip18Params(enableInboundFees = true)) assert(foundPaths.size == 2) assert(hops2Ids(foundPaths(0).path.map(graphEdgeToHop)) == 1 :: 2 :: 3 :: Nil) // A -> B -> C -> F assert(hops2Ids(foundPaths(1).path.map(graphEdgeToHop)) == 1 :: 2 :: 4 :: 5 :: 6 :: Nil) // A -> B -> C -> D -> E -> F @@ -1410,9 +1410,9 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution )), 1 day) for (_ <- 0 to 10) { - val Success(routes) = findRoute(g, a, d, DEFAULT_AMOUNT_MSAT, strictFee, numRoutes = 3, routeParams = strictFeeParams, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findRoute(g, a, d, DEFAULT_AMOUNT_MSAT, strictFee, numRoutes = 3, routeParams = strictFeeParams, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(routes.length == 2, routes) - val weightedPath = Graph.pathWeight(g.balances, a, route2Edges(routes.head), DEFAULT_AMOUNT_MSAT, BlockHeight(400000), NO_WEIGHT_RATIOS, includeLocalChannelCost = false, g.graph, enableInboundFees = false) + val weightedPath = Graph.pathWeight(g.balances, a, route2Edges(routes.head), DEFAULT_AMOUNT_MSAT, BlockHeight(400000), NO_WEIGHT_RATIOS, includeLocalChannelCost = false, g.graph, blip18 = Blip18Params.disabled) val totalFees = weightedPath.amount - DEFAULT_AMOUNT_MSAT // over the three routes we could only get the 2 cheapest because the third is too expensive (over 7 msat of fees) assert(totalFees == 5.msat || totalFees == 6.msat) @@ -1437,7 +1437,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(7L, e, c, feeBase = 2 msat, 1000, minHtlc = 0 msat, capacity = largeCapacity, cltvDelta = CltvExpiryDelta(12)) )), 1 day) - val Success(routeFeeOptimized :: Nil) = findRoute(g, a, d, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routeFeeOptimized :: Nil) = findRoute(g, a, d, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route2Nodes(routeFeeOptimized) == (a, b) :: (b, c) :: (c, d) :: Nil) val Success(routeCltvOptimized :: Nil) = findRoute(g, a, d, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(heuristics = HeuristicsConstants( @@ -1446,7 +1446,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution hopFees = RelayFees(0 msat, 0), useLogProbability = false, usePastRelaysData = false, - )), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + )), currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route2Nodes(routeCltvOptimized) == (a, e) :: (e, f) :: (f, d) :: Nil) val Success(routeCapacityOptimized :: Nil) = findRoute(g, a, d, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(heuristics = HeuristicsConstants( @@ -1455,7 +1455,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution hopFees = RelayFees(0 msat, 0), useLogProbability = false, usePastRelaysData = false, - )), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + )), currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route2Nodes(routeCapacityOptimized) == (a, e) :: (e, c) :: (c, d) :: Nil) } @@ -1475,7 +1475,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution hopFees = RelayFees(0 msat, 0), useLogProbability = false, usePastRelaysData = false, - )), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + )), currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route2Nodes(routeScoreOptimized) == (a, b) :: (b, c) :: (c, d) :: Nil) } @@ -1498,7 +1498,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution hopFees = RelayFees(500 msat, 200), useLogProbability = false, usePastRelaysData = false, - )), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + )), currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route2Nodes(routeScoreOptimized) == (a, e) :: (e, f) :: (f, d) :: Nil) } @@ -1535,26 +1535,26 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution 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), blip18InboundFees = true) + val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(routes.length == 4, routes) assert(routes.forall(_.hops.length == 1), routes) checkRouteAmounts(routes, amount, 0 msat) } { - val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = routeParams.copy(randomize = true).modify(_.mpp.splittingStrategy).setTo(Randomize), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = routeParams.copy(randomize = true).modify(_.mpp.splittingStrategy).setTo(Randomize), currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(routes.length >= 4, routes) assert(routes.forall(_.hops.length == 1), routes) checkRouteAmounts(routes, amount, 0 msat) } { - val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = routeParams.modify(_.mpp.splittingStrategy).setTo(MaxExpectedAmount), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = routeParams.modify(_.mpp.splittingStrategy).setTo(MaxExpectedAmount), currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(routes.length >= 4, routes) assert(routes.forall(_.hops.length == 1), routes) checkRouteAmounts(routes, amount, 0 msat) } { // 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, routeParams.mpp.splittingStrategy)), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val failure = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = routeParams.copy(mpp = MultiPartParams(16500 msat, 3, routeParams.mpp.splittingStrategy)), currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(failure == Failure(RouteNotFound)) } } @@ -1568,7 +1568,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution )), 1 day) val amount = 25000 msat - val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(routes.length == 1, routes) checkRouteAmounts(routes, amount, 0 msat) assert(route2Ids(routes.head) == 1L :: Nil) @@ -1584,7 +1584,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution )), 1 day) val amount = 65000 msat - val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(routes.length == 4, routes) assert(routes.forall(_.hops.length == 1), routes) checkRouteAmounts(routes, amount, 0 msat) @@ -1603,21 +1603,21 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution )), 1 day) { - val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(routes.length == 3, routes) assert(routes.forall(_.hops.length == 1), routes) checkIgnoredChannels(routes, 2L) checkRouteAmounts(routes, amount, 0 msat) } { - val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true).modify(_.mpp.splittingStrategy).setTo(Randomize), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true).modify(_.mpp.splittingStrategy).setTo(Randomize), currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(routes.length >= 3, routes) assert(routes.forall(_.hops.length == 1), routes) checkIgnoredChannels(routes, 2L) checkRouteAmounts(routes, amount, 0 msat) } { - val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS.modify(_.mpp.splittingStrategy).setTo(MaxExpectedAmount), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS.modify(_.mpp.splittingStrategy).setTo(MaxExpectedAmount), currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(routes.length >= 3, routes) assert(routes.forall(_.hops.length == 1), routes) checkIgnoredChannels(routes, 2L) @@ -1637,7 +1637,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution val amount = 20000 msat val ignoredEdges = Set(ChannelDesc(ShortChannelId(2L), a, b), ChannelDesc(ShortChannelId(3L), a, b)) - val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, ignoredEdges = ignoredEdges, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, ignoredEdges = ignoredEdges, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(routes.forall(_.hops.length == 1), routes) checkIgnoredChannels(routes, 2L, 3L) checkRouteAmounts(routes, amount, 0 msat) @@ -1658,7 +1658,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution // These pending HTLCs will have already been taken into account in the edge's `balance_opt` field: findMultiPartRoute // should ignore this information. val pendingHtlcs = Seq(Route(10000 msat, graphEdgeToHop(edge_ab_1) :: Nil, None), Route(5000 msat, graphEdgeToHop(edge_ab_2) :: Nil, None)) - val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, pendingHtlcs = pendingHtlcs, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, pendingHtlcs = pendingHtlcs, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(routes.forall(_.hops.length == 1), routes) checkRouteAmounts(routes, amount, 0 msat) } @@ -1672,7 +1672,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution )), 1 day) val amount = 50000 msat - val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(routes.forall(_.hops.length == 1), routes) assert(routes.length >= 10, routes) assert(routes.forall(_.amount <= 5000.msat), routes) @@ -1689,7 +1689,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution val amount = 30000 msat 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), blip18InboundFees = true) + val Success(routes) = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(routes.forall(_.hops.length == 1), routes) assert(routes.length == 3, routes) checkRouteAmounts(routes, amount, 0 msat) @@ -1705,10 +1705,10 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution )), 1 day) val amount = 30000 msat - val maxFeeTooLow = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val maxFeeTooLow = findMultiPartRoute(g, a, b, amount, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(maxFeeTooLow == Failure(RouteNotFound)) - val Success(routes) = findMultiPartRoute(g, a, b, amount, 20 msat, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findMultiPartRoute(g, a, b, amount, 20 msat, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(routes.forall(_.hops.length <= 2), routes) assert(routes.length == 3, routes) checkRouteAmounts(routes, amount, 20 msat) @@ -1723,15 +1723,15 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution )), 1 day) { - val result = findMultiPartRoute(g, a, b, 40000 msat, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val result = findMultiPartRoute(g, a, b, 40000 msat, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(result == Failure(RouteNotFound)) } { - val result = findMultiPartRoute(g, a, b, 40000 msat, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true).modify(_.mpp.splittingStrategy).setTo(Randomize), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val result = findMultiPartRoute(g, a, b, 40000 msat, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true).modify(_.mpp.splittingStrategy).setTo(Randomize), currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(result == Failure(RouteNotFound)) } { - val result = findMultiPartRoute(g, a, b, 40000 msat, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS.modify(_.mpp.splittingStrategy).setTo(MaxExpectedAmount), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val result = findMultiPartRoute(g, a, b, 40000 msat, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS.modify(_.mpp.splittingStrategy).setTo(MaxExpectedAmount), currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(result == Failure(RouteNotFound)) } } @@ -1744,7 +1744,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(4L, a, d, 0 msat, 0, minHtlc = 1 msat, capacity = 4500 sat), )), 1 day) - val result = findMultiPartRoute(g, a, b, 5000000 msat, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val result = findMultiPartRoute(g, a, b, 5000000 msat, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(result == Failure(RouteNotFound)) } @@ -1756,15 +1756,15 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution )), 1 day) { - val result = findMultiPartRoute(g, a, b, 10000 msat, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val result = findMultiPartRoute(g, a, b, 10000 msat, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(result == Failure(RouteNotFound)) } { - val result = findMultiPartRoute(g, a, b, 10000 msat, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true).modify(_.mpp.splittingStrategy).setTo(Randomize), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val result = findMultiPartRoute(g, a, b, 10000 msat, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true).modify(_.mpp.splittingStrategy).setTo(Randomize), currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(result == Failure(RouteNotFound)) } { - val result = findMultiPartRoute(g, a, b, 10000 msat, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS.modify(_.mpp.splittingStrategy).setTo(MaxExpectedAmount), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val result = findMultiPartRoute(g, a, b, 10000 msat, 1 msat, routeParams = DEFAULT_ROUTE_PARAMS.modify(_.mpp.splittingStrategy).setTo(MaxExpectedAmount), currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(result == Failure(RouteNotFound)) } } @@ -1787,42 +1787,42 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution )), 1 day) { - val Success(routes) = findMultiPartRoute(g, a, e, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findMultiPartRoute(g, a, e, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) checkRouteAmounts(routes, amount, maxFee) assert(routes2Ids(routes) == Set(Seq(1L, 2L, 3L), Seq(4L, 6L), Seq(5L, 6L))) } { // Update A - B with unknown balance, capacity should be used instead. val g1 = g.addEdge(makeEdge(1L, a, b, 50 msat, 100, minHtlc = 1 msat, capacity = 15 sat, balance_opt = None)) - val Success(routes) = findMultiPartRoute(g1, a, e, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findMultiPartRoute(g1, a, e, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) checkRouteAmounts(routes, amount, maxFee) assert(routes2Ids(routes) == Set(Seq(1L, 2L, 3L), Seq(4L, 6L), Seq(5L, 6L))) } { // Randomize routes. - val Success(routes) = findMultiPartRoute(g, a, e, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true).modify(_.mpp.splittingStrategy).setTo(Randomize), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findMultiPartRoute(g, a, e, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true).modify(_.mpp.splittingStrategy).setTo(Randomize), currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) checkRouteAmounts(routes, amount, maxFee) } { - val Success(routes) = findMultiPartRoute(g, a, e, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS.modify(_.mpp.splittingStrategy).setTo(MaxExpectedAmount), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findMultiPartRoute(g, a, e, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS.modify(_.mpp.splittingStrategy).setTo(MaxExpectedAmount), currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) checkRouteAmounts(routes, amount, maxFee) } { // Update balance A - B to be too low. val g1 = g.addEdge(edge_ab.copy(balance_opt = Some(2000 msat))) - val failure = findMultiPartRoute(g1, a, e, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val failure = findMultiPartRoute(g1, a, e, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(failure == Failure(RouteNotFound)) } { // Update capacity A - B to be too low. val g1 = g.addEdge(makeEdge(1L, a, b, 50 msat, 100, minHtlc = 1 msat, capacity = 5 sat, balance_opt = None)) - val failure = findMultiPartRoute(g1, a, e, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val failure = findMultiPartRoute(g1, a, e, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(failure == Failure(RouteNotFound)) } { // Try to find a route with a maxFee that's too low. val maxFeeTooLow = 100 msat - val failure = findMultiPartRoute(g, a, e, amount, maxFeeTooLow, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val failure = findMultiPartRoute(g, a, e, amount, maxFeeTooLow, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(failure == Failure(RouteNotFound)) } } @@ -1844,13 +1844,13 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution { // We can send single-part tiny payments. val (amount, maxFee) = (1400 msat, 30 msat) - val Success(routes) = findMultiPartRoute(g, a, e, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findMultiPartRoute(g, a, e, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) checkRouteAmounts(routes, amount, maxFee) } { // But we don't want to split such tiny amounts. val (amount, maxFee) = (2000 msat, 150 msat) - val failure = findMultiPartRoute(g, a, e, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val failure = findMultiPartRoute(g, a, e, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(failure == Failure(RouteNotFound)) } } @@ -1863,7 +1863,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(3L, c, d, 15 msat, 50, minHtlc = 1 msat, capacity = 150 sat), )), 1 day) - val Success(routes) = findMultiPartRoute(g, a, d, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findMultiPartRoute(g, a, d, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) checkRouteAmounts(routes, amount, maxFee) assert(routes.length == 1, "payment shouldn't be split when we have one path with enough capacity") assert(routes2Ids(routes) == Set(Seq(1L, 2L, 3L))) @@ -1888,42 +1888,42 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution )), 1 day) { - val Success(routes) = findMultiPartRoute(g, a, f, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findMultiPartRoute(g, a, f, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) checkRouteAmounts(routes, amount, maxFee) assert(routes2Ids(routes) == Set(Seq(1L, 2L, 3L, 5L), Seq(1L, 4L, 5L), Seq(1L, 6L, 7L))) } { // Randomize routes. - val Success(routes) = findMultiPartRoute(g, a, f, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true).modify(_.mpp.splittingStrategy).setTo(Randomize), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findMultiPartRoute(g, a, f, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true).modify(_.mpp.splittingStrategy).setTo(Randomize), currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) checkRouteAmounts(routes, amount, maxFee) } { - val Success(routes) = findMultiPartRoute(g, a, f, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS.modify(_.mpp.splittingStrategy).setTo(MaxExpectedAmount), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findMultiPartRoute(g, a, f, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS.modify(_.mpp.splittingStrategy).setTo(MaxExpectedAmount), currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) checkRouteAmounts(routes, amount, maxFee) } { // Update A - B with unknown balance, capacity should be used instead. val g1 = g.addEdge(makeEdge(1L, a, b, 50 msat, 100, minHtlc = 1 msat, capacity = 500 sat, balance_opt = None)) - val Success(routes) = findMultiPartRoute(g1, a, f, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findMultiPartRoute(g1, a, f, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) checkRouteAmounts(routes, amount, maxFee) assert(routes2Ids(routes) == Set(Seq(1L, 2L, 3L, 5L), Seq(1L, 4L, 5L), Seq(1L, 6L, 7L))) } { // Update balance A - B to be too low to cover fees. val g1 = g.addEdge(edge_ab.copy(balance_opt = Some(400000 msat))) - val failure = findMultiPartRoute(g1, a, f, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val failure = findMultiPartRoute(g1, a, f, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(failure == Failure(RouteNotFound)) } { // Update capacity A - B to be too low to cover fees. val g1 = g.addEdge(makeEdge(1L, a, b, 50 msat, 100, minHtlc = 1 msat, capacity = 400 sat, balance_opt = None)) - val failure = findMultiPartRoute(g1, a, f, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val failure = findMultiPartRoute(g1, a, f, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(failure == Failure(RouteNotFound)) } { // Try to find a route with a maxFee that's too low. val maxFeeTooLow = 100 msat - val failure = findMultiPartRoute(g, a, f, amount, maxFeeTooLow, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val failure = findMultiPartRoute(g, a, f, amount, maxFeeTooLow, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(failure == Failure(RouteNotFound)) } } @@ -1957,7 +1957,7 @@ class Blip18RouteCalculationSpec 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, FullCapacity)) - val Success(routes) = findMultiPartRoute(g, a, d, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findMultiPartRoute(g, a, d, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) checkRouteAmounts(routes, amount, maxFee) assert(routes2Ids(routes) == Set(Seq(100L, 101L))) } @@ -1965,14 +1965,14 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution 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, FullCapacity)) - val failure = findMultiPartRoute(g, a, d, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val failure = findMultiPartRoute(g, a, d, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) 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, FullCapacity)) - val Success(routes) = findMultiPartRoute(g, a, d, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findMultiPartRoute(g, a, d, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(routes.length == 5) routes.foreach(route => { assert(route.hops.length == 2) @@ -2008,7 +2008,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution val ignoredNodes = Set(d) val ignoredChannels = Set(ChannelDesc(ShortChannelId(2L), b, c)) - val Success(routes) = findMultiPartRoute(g, a, f, amount, maxFee, ignoredEdges = ignoredChannels, ignoredVertices = ignoredNodes, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findMultiPartRoute(g, a, f, amount, maxFee, ignoredEdges = ignoredChannels, ignoredVertices = ignoredNodes, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) checkRouteAmounts(routes, amount, maxFee) assert(routes2Ids(routes) == Set(Seq(8L), Seq(9L, 10L))) } @@ -2036,37 +2036,37 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution { val (amount, maxFee) = (15000 msat, 50 msat) - val Success(routes) = findMultiPartRoute(g, d, f, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findMultiPartRoute(g, d, f, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) checkRouteAmounts(routes, amount, maxFee) } { val (amount, maxFee) = (25000 msat, 100 msat) - val Success(routes) = findMultiPartRoute(g, d, f, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findMultiPartRoute(g, d, f, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) checkRouteAmounts(routes, amount, maxFee) } { val (amount, maxFee) = (25000 msat, 50 msat) - val failure = findMultiPartRoute(g, d, f, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val failure = findMultiPartRoute(g, d, f, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(failure == Failure(RouteNotFound)) } { val (amount, maxFee) = (40000 msat, 100 msat) - val Success(routes) = findMultiPartRoute(g, d, f, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findMultiPartRoute(g, d, f, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) checkRouteAmounts(routes, amount, maxFee) } { val (amount, maxFee) = (40000 msat, 100 msat) - val Success(routes) = findMultiPartRoute(g, d, f, amount, maxFee, routeParams = routeParams.copy(randomize = true).modify(_.mpp.splittingStrategy).setTo(Randomize), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findMultiPartRoute(g, d, f, amount, maxFee, routeParams = routeParams.copy(randomize = true).modify(_.mpp.splittingStrategy).setTo(Randomize), currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) checkRouteAmounts(routes, amount, maxFee) } { val (amount, maxFee) = (40000 msat, 100 msat) - val Success(routes) = findMultiPartRoute(g, d, f, amount, maxFee, routeParams = routeParams.modify(_.mpp.splittingStrategy).setTo(MaxExpectedAmount), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findMultiPartRoute(g, d, f, amount, maxFee, routeParams = routeParams.modify(_.mpp.splittingStrategy).setTo(MaxExpectedAmount), currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) checkRouteAmounts(routes, amount, maxFee) } { val (amount, maxFee) = (40000 msat, 50 msat) - val failure = findMultiPartRoute(g, d, f, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val failure = findMultiPartRoute(g, d, f, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(failure == Failure(RouteNotFound)) } } @@ -2087,18 +2087,18 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(11L, e, f, 5 msat, 100, minHtlc = 500 msat, capacity = 10 sat), ) - val Success(routes1) = findMultiPartRoute(g, a, e, amount, maxFeeE, extraEdges = extraEdges, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes1) = findMultiPartRoute(g, a, e, amount, maxFeeE, extraEdges = extraEdges, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) checkRouteAmounts(routes1, amount, maxFeeE) assert(routes1.length >= 3, routes1) assert(routes1.forall(_.amount <= 4000.msat), routes1) - val Success(routes2) = findMultiPartRoute(g, a, f, amount, maxFeeF, extraEdges = extraEdges, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes2) = findMultiPartRoute(g, a, f, amount, maxFeeF, extraEdges = extraEdges, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) checkRouteAmounts(routes2, amount, maxFeeF) assert(routes2.length >= 3, routes2) assert(routes2.forall(_.amount <= 4000.msat), routes2) val maxFeeTooLow = 40 msat - val failure = findMultiPartRoute(g, a, f, amount, maxFeeTooLow, extraEdges = extraEdges, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val failure = findMultiPartRoute(g, a, f, amount, maxFeeTooLow, extraEdges = extraEdges, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(failure == Failure(RouteNotFound)) } @@ -2122,7 +2122,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution )), 1 day) val pendingHtlcs = Seq(Route(5000 msat, graphEdgeToHop(edge_ab) :: graphEdgeToHop(edge_be) :: Nil, None)) - val Success(routes) = findMultiPartRoute(g, a, e, amount, maxFee, pendingHtlcs = pendingHtlcs, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findMultiPartRoute(g, a, e, amount, maxFee, pendingHtlcs = pendingHtlcs, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(routes.forall(_.hops.length == 2), routes) checkRouteAmounts(routes, amount, maxFee) checkIgnoredChannels(routes, 1L, 2L) @@ -2160,7 +2160,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(9L, c, f, Random.nextLong(250).msat, Random.nextInt(10000), minHtlc = Random.nextLong(100).msat, maxHtlc = Some((20000 + Random.nextLong(80000)).msat), CltvExpiryDelta(Random.nextInt(288)), capacity = (10 + Random.nextLong(100)).sat) )), 1 day) - findMultiPartRoute(g, d, f, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true).modify(_.mpp.splittingStrategy).setTo(Randomize), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) match { + findMultiPartRoute(g, d, f, amount, maxFee, routeParams = DEFAULT_ROUTE_PARAMS.copy(randomize = true).modify(_.mpp.splittingStrategy).setTo(Randomize), currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) match { case Success(routes) => checkRouteAmounts(routes, amount, maxFee) case Failure(ex) => assert(ex == RouteNotFound) } @@ -2188,7 +2188,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution )), 1 day) 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), blip18InboundFees = true) + val Success(routes) = findMultiPartRoute(g, a, e, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) 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))) } @@ -2211,7 +2211,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution )), 1 day) 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), blip18InboundFees = true) + val Success(routes) = findMultiPartRoute(g, a, e, amount, maxFee, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) 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))) @@ -2235,7 +2235,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(7L, f, b, 1000 msat, 1000), )), 1 day) - val Success(routes) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 3, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 3, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(routes.length == 2) val route1 :: route2 :: Nil = routes assert(route2Ids(route1) == 1 :: 5 :: Nil) @@ -2260,7 +2260,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(7L, b, f, 1000 msat, 1000), )), 1 day) - val Success(routes) = findRoute(g, e, a, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 3, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findRoute(g, e, a, DEFAULT_AMOUNT_MSAT, DEFAULT_MAX_FEE, numRoutes = 3, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(routes.length == 2) val route1 :: route2 :: Nil = routes assert(route2Ids(route1) == 5 :: 1 :: Nil) @@ -2296,7 +2296,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution val g = GraphWithBalanceEstimates(DirectedGraph(makeEdges(10)), 1 day) - val Success(routes) = findRoute(g, a, b, DEFAULT_AMOUNT_MSAT, 100000000 msat, numRoutes = 10, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findRoute(g, a, b, DEFAULT_AMOUNT_MSAT, 100000000 msat, numRoutes = 10, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(routes.distinct.length == 10) } @@ -2328,7 +2328,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution val g = GraphWithBalanceEstimates(DirectedGraph(makeEdges(10)), 1 day) - val Success(routes) = findRoute(g, a, b, DEFAULT_AMOUNT_MSAT, 100000000 msat, numRoutes = 10, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findRoute(g, a, b, DEFAULT_AMOUNT_MSAT, 100000000 msat, numRoutes = 10, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(routes.distinct.length == 10) val fees = routes.map(_.channelFee(false)) assert(fees.forall(_ == fees.head)) @@ -2339,8 +2339,8 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution makeEdge(1L, a, b, 1000 msat, 7000), )), 1 day) - assert(findRoute(g, a, b, 10000000 msat, 10000 msat, numRoutes = 3, routeParams = DEFAULT_ROUTE_PARAMS.copy(includeLocalChannelCost = true), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) == Failure(RouteNotFound)) - assert(findRoute(g, a, b, 10000000 msat, 100000 msat, numRoutes = 3, routeParams = DEFAULT_ROUTE_PARAMS.copy(includeLocalChannelCost = true), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true).isSuccess) + assert(findRoute(g, a, b, 10000000 msat, 10000 msat, numRoutes = 3, routeParams = DEFAULT_ROUTE_PARAMS.copy(includeLocalChannelCost = true), currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) == Failure(RouteNotFound)) + assert(findRoute(g, a, b, 10000000 msat, 100000 msat, numRoutes = 3, routeParams = DEFAULT_ROUTE_PARAMS.copy(includeLocalChannelCost = true), currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)).isSuccess) } test("penalty per hop") { @@ -2364,19 +2364,19 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution )), 1 day) { // No hop cost - val Success(routes) = findRoute(g, start, b, DEFAULT_AMOUNT_MSAT, 100000000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findRoute(g, start, b, DEFAULT_AMOUNT_MSAT, 100000000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(routes.distinct.length == 1) val route :: Nil = routes assert(route2Ids(route) == 0 :: 2 :: 5 :: 6 :: 7 :: 4 :: Nil) } { // small base hop cost - val Success(routes) = findRoute(g, start, b, DEFAULT_AMOUNT_MSAT, 100000000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(heuristics = HeuristicsConstants(0, RelayFees(0 msat, 0), RelayFees(100 msat, 0), useLogProbability = false, usePastRelaysData = false)), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findRoute(g, start, b, DEFAULT_AMOUNT_MSAT, 100000000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(heuristics = HeuristicsConstants(0, RelayFees(0 msat, 0), RelayFees(100 msat, 0), useLogProbability = false, usePastRelaysData = false)), currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(routes.distinct.length == 1) val route :: Nil = routes assert(route2Ids(route) == 0 :: 2 :: 3 :: 4 :: Nil) } { // large proportional hop cost - val Success(routes) = findRoute(g, start, b, DEFAULT_AMOUNT_MSAT, 100000000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(heuristics = HeuristicsConstants(0, RelayFees(0 msat, 0), RelayFees(0 msat, 200), useLogProbability = false, usePastRelaysData = false)), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findRoute(g, start, b, DEFAULT_AMOUNT_MSAT, 100000000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(heuristics = HeuristicsConstants(0, RelayFees(0 msat, 0), RelayFees(0 msat, 200), useLogProbability = false, usePastRelaysData = false)), currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(routes.distinct.length == 1) val route :: Nil = routes assert(route2Ids(route) == 0 :: 1 :: Nil) @@ -2405,7 +2405,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution useLogProbability = false, usePastRelaysData = true, ) - val Success(routes) = findRoute(g, start, b, DEFAULT_AMOUNT_MSAT, 100000000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(heuristics = hc), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findRoute(g, start, b, DEFAULT_AMOUNT_MSAT, 100000000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(heuristics = hc), currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(routes.distinct.length == 1) val route :: Nil = routes assert(route2Ids(route) == 0 :: 2 :: 3 :: 4 :: Nil) @@ -2420,7 +2420,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution useLogProbability = true, usePastRelaysData = true, ) - val Success(routes) = findRoute(g, start, b, DEFAULT_AMOUNT_MSAT, 100000000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(heuristics = hc), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findRoute(g, start, b, DEFAULT_AMOUNT_MSAT, 100000000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(heuristics = hc), currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(routes.distinct.length == 1) val route :: Nil = routes assert(route2Ids(route) == 0 :: 2 :: 3 :: 4 :: Nil) @@ -2448,7 +2448,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution useLogProbability = true, usePastRelaysData = true, ) - val Success(routes) = findRoute(g, start, b, DEFAULT_AMOUNT_MSAT, 100000000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(heuristics = hc), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findRoute(g, start, b, DEFAULT_AMOUNT_MSAT, 100000000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(heuristics = hc), currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(routes.distinct.length == 1) val route :: Nil = routes assert(route2Ids(route) == 0 :: 2 :: 3 :: 4 :: Nil) @@ -2469,7 +2469,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution useLogProbability = true, usePastRelaysData = true, ) - val Success(routes) = findRoute(g, a, c, DEFAULT_AMOUNT_MSAT, 100000000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(heuristics = hc), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findRoute(g, a, c, DEFAULT_AMOUNT_MSAT, 100000000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(heuristics = hc), currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(routes.distinct.length == 1) val route :: Nil = routes assert(route2Ids(route) == 1 :: 2 :: Nil) @@ -2492,7 +2492,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution useLogProbability = false, usePastRelaysData = false, ) - val Success(routes) = findRoute(g, a, c, DEFAULT_AMOUNT_MSAT, 100_000_000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(heuristics = wr), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findRoute(g, a, c, DEFAULT_AMOUNT_MSAT, 100_000_000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(heuristics = wr), currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(routes.distinct.length == 1) val route :: Nil = routes assert(route2Ids(route) == recentChannelId :: Nil) @@ -2504,11 +2504,11 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution { val routeParams = DEFAULT_ROUTE_PARAMS.copy(includeLocalChannelCost = true, boundaries = SearchBoundaries(100_999 msat, 0.0, 6, CltvExpiryDelta(576))) - assert(findMultiPartRoute(g, a, b, amount, 100_999 msat, Set.empty, Set.empty, Set.empty, Nil, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) == Failure(RouteNotFound)) + assert(findMultiPartRoute(g, a, b, amount, 100_999 msat, Set.empty, Set.empty, Set.empty, Nil, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) == Failure(RouteNotFound)) } { val routeParams = DEFAULT_ROUTE_PARAMS.copy(includeLocalChannelCost = true, boundaries = SearchBoundaries(101_000 msat, 0.0, 6, CltvExpiryDelta(576))) - assert(findMultiPartRoute(g, a, b, amount, 101_000 msat, Set.empty, Set.empty, Set.empty, Nil, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true).isSuccess) + assert(findMultiPartRoute(g, a, b, amount, 101_000 msat, Set.empty, Set.empty, Set.empty, Nil, routeParams = routeParams, currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)).isSuccess) } } @@ -2529,7 +2529,7 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution useLogProbability = false, usePastRelaysData = false, ) - val Success(routes) = findRoute(g, a, d, 50000 msat, 100000000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(heuristics = wr, includeLocalChannelCost = true), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(routes) = findRoute(g, a, d, 50000 msat, 100000000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(heuristics = wr, includeLocalChannelCost = true), currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) val route :: Nil = routes assert(route2Ids(route) == 3 :: 4 :: Nil) } @@ -2557,12 +2557,12 @@ class Blip18RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution useLogProbability = true, usePastRelaysData = true ) - val Success(route1 :: Nil) = findRoute(g, a, e, amount, 100000000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(heuristics = hc, includeLocalChannelCost = true), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(route1 :: Nil) = findRoute(g, a, e, amount, 100000000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(heuristics = hc, includeLocalChannelCost = true), currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route2Ids(route1) == 1 :: 2 :: 3 :: Nil) val h = g.routeCouldRelay(route1.stopAt(2)).channelCouldNotSend(route1.hops.last, amount) - val Success(route2 :: Nil) = findRoute(h, a, e, amount, 100000000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(heuristics = hc, includeLocalChannelCost = true), currentBlockHeight = BlockHeight(400000), blip18InboundFees = true) + val Success(route2 :: Nil) = findRoute(h, a, e, amount, 100000000 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(heuristics = hc, includeLocalChannelCost = true), currentBlockHeight = BlockHeight(400000), blip18 = Blip18Params(enableInboundFees = true)) assert(route2Ids(route2) == 1 :: 4 :: 5 :: Nil) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/router/GraphSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/router/GraphSpec.scala index 59259c639a..876fbf81d2 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/router/GraphSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/router/GraphSpec.scala @@ -23,7 +23,7 @@ import fr.acinq.eclair.router.Announcements.makeNodeAnnouncement import fr.acinq.eclair.router.Graph.GraphStructure.{DirectedGraph, GraphEdge} import fr.acinq.eclair.router.Graph.{HeuristicsConstants, MessagePathWeight, MessageWeightRatios, dijkstraMessagePath, routeBlindingPaths, yenKshortestPaths} import fr.acinq.eclair.router.RouteCalculationSpec._ -import fr.acinq.eclair.router.Router.ChannelDesc +import fr.acinq.eclair.router.Router.{Blip18Params, ChannelDesc} import fr.acinq.eclair.wire.protocol.Color import fr.acinq.eclair.{BlockHeight, FeatureSupport, Features, MilliSatoshiLong, RealShortChannelId, ShortChannelId, randomKey} import org.scalactic.Tolerance.convertNumericToPlusOrMinusWrapper @@ -524,13 +524,13 @@ class GraphSpec extends AnyFunSuite { .addOrUpdateVertex(makeNodeAnnouncement(priv_h, "H", Color(0, 0, 0), Nil, Features(Features.RouteBlinding -> FeatureSupport.Optional))) { - val paths = routeBlindingPaths(GraphWithBalanceEstimates(graph, 1 day), a, h, 20_000_000 msat, Set.empty, Set.empty, pathsToFind = 3, HeuristicsConstants(0, RelayFees(0 msat, 0), RelayFees(0 msat, 0), useLogProbability = false, usePastRelaysData = false), BlockHeight(793397), _ => true, excludePositiveInboundFees = false, enableInboundFees = false) + val paths = routeBlindingPaths(GraphWithBalanceEstimates(graph, 1 day), a, h, 20_000_000 msat, Set.empty, Set.empty, pathsToFind = 3, HeuristicsConstants(0, RelayFees(0 msat, 0), RelayFees(0 msat, 0), useLogProbability = false, usePastRelaysData = false), BlockHeight(793397), _ => true, blip18 = Blip18Params.disabled) assert(paths.length == 2) assert(paths(0).path.map(_.desc.a) == Seq(a, b)) assert(paths(1).path.map(_.desc.a) == Seq(a, e, f)) } { - val paths = routeBlindingPaths(GraphWithBalanceEstimates(graph, 1 day), c, h, 20_000_000 msat, Set.empty, Set.empty, pathsToFind = 3, HeuristicsConstants(0, RelayFees(0 msat, 0), RelayFees(0 msat, 0), useLogProbability = false, usePastRelaysData = false), BlockHeight(793397), _ => true, excludePositiveInboundFees = false, enableInboundFees = false) + val paths = routeBlindingPaths(GraphWithBalanceEstimates(graph, 1 day), c, h, 20_000_000 msat, Set.empty, Set.empty, pathsToFind = 3, HeuristicsConstants(0, RelayFees(0 msat, 0), RelayFees(0 msat, 0), useLogProbability = false, usePastRelaysData = false), BlockHeight(793397), _ => true, blip18 = Blip18Params.disabled) assert(paths.length == 1) assert(paths(0).path.map(_.desc.a) == Seq(c, a, b)) } 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 9dd3e98658..8b75a34fc9 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 @@ -125,7 +125,7 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution { )), 1 day) val Success(route :: Nil) = findRoute(graph, a, d, amount, maxFee = 7 msat, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000)) - val weightedPath = Graph.pathWeight(graph.balances, a, route2Edges(route), amount, BlockHeight(0), NO_WEIGHT_RATIOS, includeLocalChannelCost = false, graph.graph, enableInboundFees = false) + val weightedPath = Graph.pathWeight(graph.balances, a, route2Edges(route), amount, BlockHeight(0), NO_WEIGHT_RATIOS, includeLocalChannelCost = false, graph.graph, blip18 = Blip18Params.disabled) assert(route2Ids(route) == 4 :: 5 :: 6 :: Nil) assert(weightedPath.length == 3) assert(weightedPath.amount == expectedCost) @@ -787,7 +787,7 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution { for (_ <- 0 to 10) { val Success(routes) = findRoute(g, a, d, DEFAULT_AMOUNT_MSAT, strictFee, numRoutes = 3, routeParams = strictFeeParams, currentBlockHeight = BlockHeight(400000)) assert(routes.length == 2, routes) - val weightedPath = Graph.pathWeight(g.balances, a, route2Edges(routes.head), DEFAULT_AMOUNT_MSAT, BlockHeight(400000), NO_WEIGHT_RATIOS, includeLocalChannelCost = false, g.graph, enableInboundFees = false) + val weightedPath = Graph.pathWeight(g.balances, a, route2Edges(routes.head), DEFAULT_AMOUNT_MSAT, BlockHeight(400000), NO_WEIGHT_RATIOS, includeLocalChannelCost = false, g.graph, blip18 = Blip18Params.disabled) val totalFees = weightedPath.amount - DEFAULT_AMOUNT_MSAT // over the three routes we could only get the 2 cheapest because the third is too expensive (over 7 msat of fees) assert(totalFees == 5.msat || totalFees == 6.msat) From 16a0318d6e00d7e3d76b22092b390390253c9e56 Mon Sep 17 00:00:00 2001 From: rorp Date: Sun, 5 Jul 2026 18:39:24 -0700 Subject: [PATCH 08/11] Fix bLIP-18 inbound fee edge cases. --- docs/release-notes/eclair-vnext.md | 3 +- .../main/scala/fr/acinq/eclair/Eclair.scala | 18 ++++++--- .../fr/acinq/eclair/channel/ChannelData.scala | 2 +- .../fr/acinq/eclair/channel/fsm/Channel.scala | 20 ++++++++-- .../fr/acinq/eclair/db/InboundFeesDb.scala | 20 +++++++++- .../acinq/eclair/db/pg/PgInboundFeesDb.scala | 27 ++++++++++++- .../db/sqlite/SqliteInboundFeesDb.scala | 24 +++++++++++- .../eclair/payment/relay/ChannelRelay.scala | 16 ++++++-- .../eclair/payment/relay/ChannelRelayer.scala | 17 +++++++-- .../payment/send/PaymentLifecycle.scala | 6 ++- .../acinq/eclair/router/Announcements.scala | 1 + .../scala/fr/acinq/eclair/router/Graph.scala | 12 +++++- .../scala/fr/acinq/eclair/router/Router.scala | 4 +- .../fr/acinq/eclair/EclairImplSpec.scala | 10 ++--- .../ChannelStateTestsHelperMethods.scala | 6 ++- .../channel/states/e/NormalStateSpec.scala | 16 +++++++- .../acinq/eclair/db/InboundFeesDbSpec.scala | 38 ++++++++++++++++++- .../router/Blip18FinalizeRouteSpec.scala | 18 ++++++++- .../router/Blip18RouteCalculationSpec.scala | 18 ++++++++- .../fr/acinq/eclair/api/handlers/Fees.scala | 10 ++--- 20 files changed, 241 insertions(+), 45 deletions(-) diff --git a/docs/release-notes/eclair-vnext.md b/docs/release-notes/eclair-vnext.md index 3efafdcf55..956692b223 100644 --- a/docs/release-notes/eclair-vnext.md +++ b/docs/release-notes/eclair-vnext.md @@ -34,7 +34,8 @@ named `inboundfees.sqlite.bak`, next to `eclair.sqlite.bak`: if you set inbound -- `updaterelayfee` now accepts optional `--inboundFeeBaseMsat` and `--inboundFeeProportionalMillionths` parameters. If omitted, existing inbound fees will be preserved. +- `updaterelayfee` now accepts optional `--inboundFeeBaseMsat` and `--inboundFeeProportionalMillionths` parameters (both must be negative or zero, since only inbound discounts are supported). If omitted, existing inbound fees will be preserved. +- `updaterelayfee` now accepts an optional `--unsetInboundFees` flag that removes any previously advertised inbound fees. It cannot be combined with `--inboundFeeBaseMsat` or `--inboundFeeProportionalMillionths`. ### Miscellaneous improvements and bug fixes diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala index d495d37010..4494b8e9aa 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala @@ -114,7 +114,7 @@ trait Eclair { def updateRelayFee(nodes: List[PublicKey], feeBase: MilliSatoshi, feeProportionalMillionths: Long)(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_UPDATE_RELAY_FEE]]]] - def updateRelayFee(nodes: List[PublicKey], feeBase: MilliSatoshi, feeProportionalMillionths: Long, inboundFeeBase_opt: Option[MilliSatoshi], inboundFeeProportional_opt: Option[Long])(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_UPDATE_RELAY_FEE]]]] + def updateRelayFee(nodes: List[PublicKey], feeBase: MilliSatoshi, feeProportionalMillionths: Long, inboundFeeBase_opt: Option[MilliSatoshi], inboundFeeProportional_opt: Option[Long], unsetInboundFees: Boolean)(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_UPDATE_RELAY_FEE]]]] def channelsInfo(toRemoteNode_opt: Option[PublicKey])(implicit timeout: Timeout): Future[Iterable[RES_GET_CHANNEL_INFO]] @@ -314,19 +314,25 @@ class EclairImpl(val appKit: Kit) extends Eclair with Logging with SpendFromChan } override def updateRelayFee(nodes: List[PublicKey], feeBaseMsat: MilliSatoshi, feeProportionalMillionths: Long)(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_UPDATE_RELAY_FEE]]]] = - updateRelayFee(nodes, feeBaseMsat, feeProportionalMillionths, None, None) + updateRelayFee(nodes, feeBaseMsat, feeProportionalMillionths, None, None, unsetInboundFees = false) - override def updateRelayFee(nodes: List[PublicKey], feeBaseMsat: MilliSatoshi, feeProportionalMillionths: Long, inboundFeeBase_opt: Option[MilliSatoshi], inboundFeeProportional_opt: Option[Long])(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_UPDATE_RELAY_FEE]]]] = { + override def updateRelayFee(nodes: List[PublicKey], feeBaseMsat: MilliSatoshi, feeProportionalMillionths: Long, inboundFeeBase_opt: Option[MilliSatoshi], inboundFeeProportional_opt: Option[Long], unsetInboundFees: Boolean)(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_UPDATE_RELAY_FEE]]]] = { if ((inboundFeeBase_opt.isDefined || inboundFeeProportional_opt.isDefined) && !appKit.nodeParams.routerConf.blip18.enableInboundFees) { Future.failed(new IllegalArgumentException("Cannot specify inbound fees when bLIP-18 support is disabled")) + } else if (!inboundFeeBase_opt.forall(value => value.toLong >= Int.MinValue && value.toLong <= 0)) { + Future.failed(new IllegalArgumentException(s"Inbound fee base must be in the range from ${Int.MinValue} to 0")) + } else if (!inboundFeeProportional_opt.forall(value => value >= Int.MinValue && value <= 0)) { + Future.failed(new IllegalArgumentException(s"Inbound fee proportional millionths must be in the range from ${Int.MinValue} to 0")) } else { + val inboundFees_opt = InboundFees.fromOptions(inboundFeeBase_opt, inboundFeeProportional_opt) for (nodeId <- nodes) { appKit.nodeParams.db.peers.addOrUpdateRelayFees(nodeId, RelayFees(feeBaseMsat, feeProportionalMillionths)) - InboundFees.fromOptions(inboundFeeBase_opt, inboundFeeProportional_opt).foreach { inboundFees => - appKit.nodeParams.db.inboundFees.addOrUpdateInboundFees(nodeId, inboundFees) + inboundFees_opt match { + case Some(inboundFees) => appKit.nodeParams.db.inboundFees.addOrUpdateInboundFees(nodeId, inboundFees) + case _ => if (unsetInboundFees) appKit.nodeParams.db.inboundFees.unsetInboundFees(nodeId) } } - sendToNodes(nodes, CMD_UPDATE_RELAY_FEE(ActorRef.noSender, feeBaseMsat, feeProportionalMillionths, inboundFeeBase_opt, inboundFeeProportional_opt)) + sendToNodes(nodes, CMD_UPDATE_RELAY_FEE(ActorRef.noSender, feeBaseMsat, feeProportionalMillionths, inboundFeeBase_opt, inboundFeeProportional_opt, if (inboundFees_opt.isDefined) false else unsetInboundFees)) } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala index 6b904a48ea..ba1d23ab44 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala @@ -270,7 +270,7 @@ final case class CMD_SPLICE(replyTo: akka.actor.typed.ActorRef[CommandResponse[C val spliceOutputs: List[TxOut] = spliceOut_opt.toList.map(s => TxOut(s.amount, s.scriptPubKey)) } final case class CMD_BUMP_FUNDING_FEE(replyTo: akka.actor.typed.ActorRef[CommandResponse[ChannelFundingCommand]], targetFeerate: FeeratePerKw, fundingFeeBudget: Satoshi, lockTime: Long, requestFunding_opt: Option[LiquidityAds.RequestFunding]) extends ChannelFundingCommand -final case class CMD_UPDATE_RELAY_FEE(replyTo: ActorRef, feeBase: MilliSatoshi, feeProportionalMillionths: Long, inboundFeeBase_opt: Option[MilliSatoshi] = None, inboundFeeProportionalMillionths_opt: Option[Long]= None) extends HasReplyToCommand +final case class CMD_UPDATE_RELAY_FEE(replyTo: ActorRef, feeBase: MilliSatoshi, feeProportionalMillionths: Long, inboundFeeBase_opt: Option[MilliSatoshi] = None, inboundFeeProportionalMillionths_opt: Option[Long] = None, unsetInboundFees: Boolean = false) extends HasReplyToCommand final case class CMD_GET_CHANNEL_STATE(replyTo: ActorRef) extends HasReplyToCommand final case class CMD_GET_CHANNEL_DATA(replyTo: ActorRef) extends HasReplyToCommand final case class CMD_GET_CHANNEL_INFO(replyTo: akka.actor.typed.ActorRef[RES_GET_CHANNEL_INFO]) extends Command diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala index c7bf30ea50..c1337b8cae 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala @@ -934,8 +934,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall } case Event(c: CMD_UPDATE_RELAY_FEE, d: DATA_NORMAL) => - // If inbound fees are omitted, we preserve the ones currently advertised in our channel_update. - val inboundFees_opt = InboundFees.fromOptions(c.inboundFeeBase_opt, c.inboundFeeProportionalMillionths_opt).orElse(d.channelUpdate.blip18InboundFees_opt) + val inboundFees_opt = updatedInboundFees(c, d) val channelUpdate1 = Helpers.channelUpdate(nodeParams, scidForChannelUpdate(d), d.commitments, Relayer.RelayFees(c.feeBase, c.feeProportionalMillionths), enable = true, inboundFees_opt) log.debug(s"updating relay fees: prev={} next={}", d.channelUpdate.toStringShort, channelUpdate1.toStringShort) val replyTo = if (c.replyTo == ActorRef.noSender) sender() else c.replyTo @@ -3361,9 +3360,22 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall } } + /** + * Compute the bLIP-18 inbound fees to advertise for a relay fee update. When inbound fees are omitted from the command + * we preserve the ones currently advertised in our channel_update. When bLIP-18 support is disabled we never advertise + * inbound fees: this ensures a node that disables the feature actually stops advertising (and honouring) inbound fees, + * instead of keeping a stale discount forever (which would also make the restore-time refresh loop indefinitely). + */ + private def updatedInboundFees(c: CMD_UPDATE_RELAY_FEE, d: DATA_NORMAL): Option[InboundFees] = { + if (nodeParams.routerConf.blip18.enableInboundFees) { + InboundFees.fromOptions(c.inboundFeeBase_opt, c.inboundFeeProportionalMillionths_opt).orElse(if (c.unsetInboundFees) None else d.channelUpdate.blip18InboundFees_opt) + } else { + None + } + } + private def handleUpdateRelayFeeDisconnected(c: CMD_UPDATE_RELAY_FEE, d: DATA_NORMAL) = { - // If inbound fees are omitted, we preserve the ones currently advertised in our channel_update. - val inboundFees_opt = InboundFees.fromOptions(c.inboundFeeBase_opt, c.inboundFeeProportionalMillionths_opt).orElse(d.channelUpdate.blip18InboundFees_opt) + val inboundFees_opt = updatedInboundFees(c, d) val channelUpdate1 = Helpers.channelUpdate(nodeParams, scidForChannelUpdate(d), d.commitments, Relayer.RelayFees(c.feeBase, c.feeProportionalMillionths), enable = false, inboundFees_opt) log.debug(s"updating relay fees: prev={} next={}", d.channelUpdate.toStringShort, channelUpdate1.toStringShort) val replyTo = if (c.replyTo == ActorRef.noSender) sender() else c.replyTo diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/InboundFeesDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/InboundFeesDb.scala index e97d49f7d3..844e1e3d16 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/InboundFeesDb.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/InboundFeesDb.scala @@ -1,3 +1,19 @@ +/* + * Copyright 2025 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package fr.acinq.eclair.db import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey @@ -10,4 +26,6 @@ trait InboundFeesDb { def getInboundFees(nodeId: PublicKey): Option[InboundFees] -} + def unsetInboundFees(nodeId: PublicKey): Unit + +} \ No newline at end of file diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgInboundFeesDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgInboundFeesDb.scala index 4dceedd8af..b97877df18 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgInboundFeesDb.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/pg/PgInboundFeesDb.scala @@ -1,3 +1,19 @@ +/* + * Copyright 2025 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package fr.acinq.eclair.db.pg import fr.acinq.bitcoin.scalacompat.Crypto @@ -66,5 +82,12 @@ class PgInboundFeesDb(implicit ds: DataSource, lock: PgLock) extends InboundFees } } -} - + override def unsetInboundFees(nodeId: Crypto.PublicKey): Unit = withMetrics("inbound-fees/unset-inbound-fees", DbBackends.Postgres) { + withLock { pg => + using(pg.prepareStatement("DELETE FROM inboundfees.inbound_fees WHERE node_id=?")) { statement => + statement.setString(1, nodeId.value.toHex) + statement.executeUpdate() + } + } + } +} \ No newline at end of file diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteInboundFeesDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteInboundFeesDb.scala index d1bb362192..8a26122f5d 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteInboundFeesDb.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/sqlite/SqliteInboundFeesDb.scala @@ -1,3 +1,19 @@ +/* + * Copyright 2025 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package fr.acinq.eclair.db.sqlite import fr.acinq.bitcoin.scalacompat.Crypto @@ -60,4 +76,10 @@ class SqliteInboundFeesDb(val sqlite: Connection) extends InboundFeesDb with Log } -} + override def unsetInboundFees(nodeId: Crypto.PublicKey): Unit = withMetrics("inbound-fees/unset-inbound-fees", DbBackends.Sqlite) { + using(sqlite.prepareStatement("DELETE FROM inbound_fees WHERE node_id=?")) { statement => + statement.setBytes(1, nodeId.value.toArray) + statement.executeUpdate() + } + } +} \ No newline at end of file diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelay.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelay.scala index 600eb86075..34f0e6efe8 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelay.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelay.scala @@ -70,7 +70,8 @@ object ChannelRelay { relayId: UUID, r: IncomingPaymentPacket.ChannelRelayPacket, incomingChannelOccupancy: Double, - inboundFees_opt: Option[InboundFees]): Behavior[Command] = + inboundFees_opt: Option[InboundFees], + prevInboundFees_opt: Option[Option[InboundFees]]): Behavior[Command] = Behaviors.setup { context => Behaviors.withMdc(Logs.mdc( category_opt = Some(Logs.LogCategory.PAYMENT), @@ -97,7 +98,7 @@ object ChannelRelay { } Behaviors.receiveMessagePartial { case WrappedReputationScore(score) => - new ChannelRelay(nodeParams, register, channels, r, upstream, score, inboundFees_opt, context).start() + new ChannelRelay(nodeParams, register, channels, r, upstream, score, inboundFees_opt, prevInboundFees_opt, context).start() } } } @@ -145,6 +146,7 @@ class ChannelRelay private(nodeParams: NodeParams, upstream: Upstream.Hot.Channel, reputationScore: Reputation.Score, inboundFees_opt: Option[InboundFees], + prevInboundFees_opt: Option[Option[InboundFees]], context: ActorContext[ChannelRelay.Command]) { import ChannelRelay._ @@ -436,8 +438,14 @@ class ChannelRelay private(nodeParams: NodeParams, val prevUpdate_opt = if (allowPreviousUpdate) outgoingChannel.prevChannelUpdate else None val htlcMinimumOk = update.htlcMinimumMsat <= r.amountToForward || prevUpdate_opt.exists(_.htlcMinimumMsat <= r.amountToForward) val expiryDeltaOk = update.cltvExpiryDelta <= r.expiryDelta || prevUpdate_opt.exists(_.cltvExpiryDelta <= r.expiryDelta) - val feesOk = totalFee(r.amountToForward, update.relayFees, inboundFees_opt) <= r.relayFeeMsat || - prevUpdate_opt.exists(u => totalFee(r.amountToForward, u.relayFees, inboundFees_opt) <= r.relayFeeMsat) + // The fee is sufficient if it covers any accepted combination of outbound relay fees (current or, within the + // enforcement delay, previous outgoing channel_update) and inbound fees (current or, within the enforcement delay, + // previously advertised inbound fees on the incoming channel). + val acceptableInboundFees: Seq[Option[InboundFees]] = (inboundFees_opt +: prevInboundFees_opt.toSeq).distinct + val feesOk = acceptableInboundFees.exists { inbound => + totalFee(r.amountToForward, update.relayFees, inbound) <= r.relayFeeMsat || + prevUpdate_opt.exists(u => totalFee(r.amountToForward, u.relayFees, inbound) <= r.relayFeeMsat) + } if (!htlcMinimumOk) { Some(makeCmdFailHtlc(r.add.id, AmountBelowMinimum(r.amountToForward, Some(update)))) } else if (!expiryDeltaOk) { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelayer.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelayer.scala index b7edb5b55a..5cc040566a 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelayer.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelayer.scala @@ -25,7 +25,7 @@ import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.eclair.channel._ import fr.acinq.eclair.payment.IncomingPaymentPacket import fr.acinq.eclair.reputation.ReputationRecorder -import fr.acinq.eclair.{Logs, NodeParams, ShortChannelId, SubscriptionsComplete} +import fr.acinq.eclair.{Logs, NodeParams, ShortChannelId, SubscriptionsComplete, TimestampSecond} import java.util.UUID import scala.collection.mutable @@ -84,13 +84,24 @@ object ChannelRelayer { case None => Map.empty } // bLIP-18 inbound fees advertised in our channel_update for the incoming channel apply to this relay. + val incomingChannel_opt = channels.get(channelRelayPacket.add.channelId) val inboundFees_opt = if (nodeParams.routerConf.blip18.enableInboundFees) { - channels.get(channelRelayPacket.add.channelId).flatMap(_.channelUpdate.blip18InboundFees_opt) + incomingChannel_opt.flatMap(_.channelUpdate.blip18InboundFees_opt) + } else { + None + } + // If we recently updated the inbound fees of the incoming channel, senders may still be using the previously + // advertised inbound fees: we tolerate them during the enforcement delay, just like we do for outbound fees. + val prevInboundFees_opt = if (nodeParams.routerConf.blip18.enableInboundFees) { + incomingChannel_opt + .filter(c => TimestampSecond.now() - c.channelUpdate.timestamp <= nodeParams.relayParams.enforcementDelay) + .flatMap(_.prevChannelUpdate) + .map(_.blip18InboundFees_opt) } else { None } context.log.debug(s"spawning a new handler with relayId=$relayId to nextNodeId={} with channels={}", nextNodeId_opt.getOrElse(""), nextChannels.keys.mkString(",")) - context.spawn(ChannelRelay.apply(nodeParams, register, reputationRecorder_opt, nextChannels, originNode, relayId, channelRelayPacket, incomingChannelOccupancy, inboundFees_opt), name = relayId.toString) + context.spawn(ChannelRelay.apply(nodeParams, register, reputationRecorder_opt, nextChannels, originNode, relayId, channelRelayPacket, incomingChannelOccupancy, inboundFees_opt, prevInboundFees_opt), name = relayId.toString) Behaviors.same case GetOutgoingChannels(replyTo, Relayer.GetOutgoingChannels(enabledOnly)) => 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 96b4d94ef4..81b9ff7e01 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 @@ -213,8 +213,10 @@ class PaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, router: A case TemporaryChannelFailure(update_opt, _) => val failingHop = route.hops(index) val isLiquidityIssue = update_opt match { - // If the relay parameters have changed, it's not necessarily a liquidity issue. - case Some(update) => HopRelayParams.areSame(failingHop.params, HopRelayParams.FromAnnouncement(update), ignoreHtlcSize = true) + // If the relay parameters have changed, it's not necessarily a liquidity issue. We ignore inbound fees in + // this comparison: the failing hop's inbound fees were derived from its predecessor's channel_update during + // path-finding, whereas `update` is the failing hop's own channel_update, so the two aren't comparable. + case Some(update) => HopRelayParams.areSame(failingHop.params, HopRelayParams.FromAnnouncement(update), ignoreHtlcSize = true, ignoreInboundFees = true) case None => true } if (isLiquidityIssue) { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/router/Announcements.scala b/eclair-core/src/main/scala/fr/acinq/eclair/router/Announcements.scala index 56e02ed362..0d551a7666 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/router/Announcements.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/router/Announcements.scala @@ -119,6 +119,7 @@ object Announcements { def areSameRelayParams(u1: ChannelUpdate, u2: ChannelUpdate): Boolean = u1.feeBaseMsat == u2.feeBaseMsat && u1.feeProportionalMillionths == u2.feeProportionalMillionths && + u1.blip18InboundFees_opt == u2.blip18InboundFees_opt && u1.cltvExpiryDelta == u2.cltvExpiryDelta && u1.htlcMinimumMsat == u2.htlcMinimumMsat && u1.htlcMaximumMsat == u2.htlcMaximumMsat 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 9c4b4fd2b1..ceff4a4c6f 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 @@ -261,7 +261,10 @@ object Graph { // [...] --> B --+--> D --> E // | // [...] --> C --+ - val alreadyExploredEdges = shortestPaths.collect { case p if p.p.path.takeRight(i) == rootPathEdges => p.p.path(p.p.path.length - 1 - i).desc }.toSet + // We compare channel descriptors rather than full edges: bLIP-18 enrichment stamps predecessor-dependent + // inbound fees onto edge params, so two topologically identical suffixes reached from different + // predecessors would otherwise compare unequal and fail to exclude the already-explored edge. + val alreadyExploredEdges = shortestPaths.collect { case p if p.p.path.takeRight(i).map(_.desc) == rootPathEdges.map(_.desc) => p.p.path(p.p.path.length - 1 - i).desc }.toSet // we also want to ignore any vertex on the root path to prevent loops val alreadyExploredVertices = rootPathEdges.map(_.desc.b).toSet val rootPathWeight = pathWeight(g.balances, sourceNode, rootPathEdges, amount, currentBlockHeight, wr, includeLocalChannelCost, g.graph, blip18) @@ -272,7 +275,12 @@ object Graph { // concatenation with the spur path their actual predecessor changes, so we recompute inbound fees. val completePath = enrichPathWithInboundFees(spurPath ++ rootPathEdges, g.graph, blip18) val candidatePath = WeightedPath(completePath, pathWeight(g.balances, sourceNode, completePath, amount, currentBlockHeight, wr, includeLocalChannelCost, g.graph, blip18)) - candidates.enqueue(PathWithSpur(candidatePath, i)) + // The spur search treats the spur node as its target and therefore skips that node's inbound fees; the + // recomputed weight above includes them, so we must re-check the boundaries (e.g. maxFee) before + // accepting the candidate, otherwise we could return a route whose actual fee exceeds the budget. + if (boundaries(candidatePath.weight)) { + candidates.enqueue(PathWithSpur(candidatePath, i)) + } case None => () } } 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 7ed22ddcd7..be0689c17e 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 @@ -551,10 +551,10 @@ object Router { override val htlcMaximum_opt = extraHop.htlcMaximum_opt } - def areSame(a: HopRelayParams, b: HopRelayParams, ignoreHtlcSize: Boolean = false): Boolean = + def areSame(a: HopRelayParams, b: HopRelayParams, ignoreHtlcSize: Boolean = false, ignoreInboundFees: Boolean = false): Boolean = a.cltvExpiryDelta == b.cltvExpiryDelta && a.relayFees == b.relayFees && - a.inboundFees_opt == b.inboundFees_opt && + (ignoreInboundFees || a.inboundFees_opt == b.inboundFees_opt) && (ignoreHtlcSize || (a.htlcMinimum == b.htlcMinimum && a.htlcMaximum_opt == b.htlcMaximum_opt)) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala index 49b6cba441..3b33101536 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala @@ -676,7 +676,7 @@ class EclairImplSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with I val b1 = randomBytes32() val map = Map(a1 -> a, a2 -> a, b1 -> b) - eclair.updateRelayFee(List(a, b), 999 msat, 1234, Some(1 msat), Some(2)).pipeTo(sender.ref) + eclair.updateRelayFee(List(a, b), 999 msat, 1234, Some(-1 msat), Some(-2), false).pipeTo(sender.ref) register.expectMsg(Register.GetChannelsTo) register.reply(map) @@ -690,16 +690,16 @@ class EclairImplSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with I register.expectNoMessage() assert(sender.expectMsgType[Map[ChannelIdentifier, Either[Throwable, CommandResponse[CMD_UPDATE_RELAY_FEE]]]] == Map( - Left(a1) -> Right(RES_SUCCESS(CMD_UPDATE_RELAY_FEE(ActorRef.noSender, 999 msat, 1234, Some(1 msat), Some(2)), a1)), - Left(a2) -> Right(RES_FAILURE(CMD_UPDATE_RELAY_FEE(ActorRef.noSender, 999 msat, 1234, Some(1 msat), Some(2)), CommandUnavailableInThisState(a2, "CMD_UPDATE_RELAY_FEE", channel.CLOSING))), + Left(a1) -> Right(RES_SUCCESS(CMD_UPDATE_RELAY_FEE(ActorRef.noSender, 999 msat, 1234, Some(-1 msat), Some(-2)), a1)), + Left(a2) -> Right(RES_FAILURE(CMD_UPDATE_RELAY_FEE(ActorRef.noSender, 999 msat, 1234, Some(-1 msat), Some(-2)), CommandUnavailableInThisState(a2, "CMD_UPDATE_RELAY_FEE", channel.CLOSING))), Left(b1) -> Left(ChannelNotFound(Left(b1))) )) peersDb.addOrUpdateRelayFees(a, RelayFees(999 msat, 1234)).wasCalled(once) peersDb.addOrUpdateRelayFees(b, RelayFees(999 msat, 1234)).wasCalled(once) - inboundFeesDb.addOrUpdateInboundFees(a, InboundFees(1 msat, 2)).wasCalled(once) - inboundFeesDb.addOrUpdateInboundFees(b, InboundFees(1 msat, 2)).wasCalled(once) + inboundFeesDb.addOrUpdateInboundFees(a, InboundFees(-1 msat, -2)).wasCalled(once) + inboundFeesDb.addOrUpdateInboundFees(b, InboundFees(-1 msat, -2)).wasCalled(once) } test("channelBalances asks for all channels, usableBalances only for enabled ones") { f => diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala index d52eed6e28..13c394509a 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala @@ -36,7 +36,7 @@ import fr.acinq.eclair.channel.states.ChannelStateTestsBase.{FakeTxPublisherFact import fr.acinq.eclair.payment.send.SpontaneousRecipient import fr.acinq.eclair.payment.{Invoice, OutgoingPaymentPacket} import fr.acinq.eclair.reputation.Reputation -import fr.acinq.eclair.router.Router.{ChannelHop, HopRelayParams, Route} +import fr.acinq.eclair.router.Router.{Blip18Params, ChannelHop, HopRelayParams, Route} import fr.acinq.eclair.testutils.PimpTestProbe.convert import fr.acinq.eclair.transactions.Transactions._ import fr.acinq.eclair.wire.protocol._ @@ -100,6 +100,8 @@ object ChannelStateTestsTags { val OptionSimpleTaproot = "option_simple_taproot" /** If set, channel will use zero-fee commitments. */ val ZeroFeeCommitments = "zero_fee_commitments" + /** If set, bLIP-18 inbound fees are enabled. */ + val Blip18InboundFees = "blip18_inbound_fees" } trait ChannelStateTestsBase extends Assertions with Eventually { @@ -231,6 +233,7 @@ trait ChannelStateTestsBase extends Assertions with Eventually { .modify(_.onChainFeeConf.defaultFeerateTolerance.ratioHigh).setToIf(tags.contains(ChannelStateTestsTags.HighFeerateMismatchTolerance))(1000000) .modify(_.onChainFeeConf.spendAnchorWithoutHtlcs).setToIf(tags.contains(ChannelStateTestsTags.DontSpendAnchorWithoutHtlcs))(false) .modify(_.channelConf.balanceThresholds).setToIf(tags.contains(ChannelStateTestsTags.AdaptMaxHtlcAmount))(Seq(Channel.BalanceThreshold(1_000 sat, 0 sat), Channel.BalanceThreshold(5_000 sat, 1_000 sat), Channel.BalanceThreshold(10_000 sat, 5_000 sat))) + .modify(_.routerConf.blip18.enableInboundFees).setToIf(tags.contains(ChannelStateTestsTags.Blip18InboundFees))(true) val finalNodeParamsB = nodeParamsB1 .modify(_.channelConf.dustLimit).setToIf(tags.contains(ChannelStateTestsTags.HighDustLimitDifferenceAliceBob))(1000 sat) .modify(_.channelConf.dustLimit).setToIf(tags.contains(ChannelStateTestsTags.HighDustLimitDifferenceBobAlice))(5000 sat) @@ -244,6 +247,7 @@ trait ChannelStateTestsBase extends Assertions with Eventually { .modify(_.onChainFeeConf.defaultFeerateTolerance.ratioHigh).setToIf(tags.contains(ChannelStateTestsTags.HighFeerateMismatchTolerance))(1000000) .modify(_.onChainFeeConf.spendAnchorWithoutHtlcs).setToIf(tags.contains(ChannelStateTestsTags.DontSpendAnchorWithoutHtlcs))(false) .modify(_.channelConf.balanceThresholds).setToIf(tags.contains(ChannelStateTestsTags.AdaptMaxHtlcAmount))(Seq(Channel.BalanceThreshold(1_000 sat, 0 sat), Channel.BalanceThreshold(5_000 sat, 1_000 sat), Channel.BalanceThreshold(10_000 sat, 5_000 sat))) + .modify(_.routerConf.blip18.enableInboundFees).setToIf(tags.contains(ChannelStateTestsTags.Blip18InboundFees))(true) val aliceWallet = walletA_opt.getOrElse(new SingleKeyOnChainWallet()) val alice: TestFSMRef[ChannelState, ChannelData, Channel] = { implicit val system: ActorSystem = systemA diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala index b32e8fdca7..8103964b1f 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala @@ -2237,7 +2237,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with alice2relayer.expectNoMessage(100 millis) } - test("recv CMD_UPDATE_RELAY_FEE (preserves inbound fees when omitted)") { f => + test("recv CMD_UPDATE_RELAY_FEE (preserves inbound fees when omitted)", Tag(ChannelStateTestsTags.Blip18InboundFees)) { f => import f._ val sender = TestProbe() val newFeeBaseMsat = TestConstants.Alice.nodeParams.relayParams.publicChannelFees.feeBase * 2 @@ -2257,6 +2257,20 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with alice2relayer.expectNoMessage(100 millis) } + test("recv CMD_UPDATE_RELAY_FEE (does not advertise inbound fees when bLIP-18 is disabled)") { f => + import f._ + val sender = TestProbe() + val newFeeBaseMsat = TestConstants.Alice.nodeParams.relayParams.publicChannelFees.feeBase * 2 + val newFeeProportionalMillionth = TestConstants.Alice.nodeParams.relayParams.publicChannelFees.feeProportionalMillionths * 2 + // Even if inbound fees are provided in the command, we don't advertise them when the feature is disabled: otherwise + // we would advertise fees that the relay path doesn't honour, and could never clear them once the feature is off. + sender.send(alice, CMD_UPDATE_RELAY_FEE(ActorRef.noSender, newFeeBaseMsat, newFeeProportionalMillionth, Some(-100 msat), Some(-50))) + sender.expectMsgType[RES_SUCCESS[CMD_UPDATE_RELAY_FEE]] + val localUpdate = channelUpdateListener.expectMsgType[LocalChannelUpdate] + assert(localUpdate.channelUpdate.blip18InboundFees_opt.isEmpty) + alice2relayer.expectNoMessage(100 millis) + } + def testCmdClose(f: FixtureParam, script_opt: Option[ByteVector]): Unit = { import f._ val sender = TestProbe() diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/db/InboundFeesDbSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/db/InboundFeesDbSpec.scala index a896ed0d47..1a16bc55ef 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/db/InboundFeesDbSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/db/InboundFeesDbSpec.scala @@ -1,3 +1,19 @@ +/* + * Copyright 2025 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package fr.acinq.eclair.db import fr.acinq.eclair.TestDatabases.{TestPgDatabases, TestSqliteDatabases} @@ -42,4 +58,24 @@ class InboundFeesDbSpec extends AnyFunSuite { assert(db.getInboundFees(b).contains(InboundFees(3 msat, 789))) } } -} + + test("unset inbound fees") { + forAllDbs { dbs => + val db = dbs.inboundFees + + val a = randomKey().publicKey + val b = randomKey().publicKey + + assert(db.getInboundFees(a).isEmpty) + assert(db.getInboundFees(b).isEmpty) + db.addOrUpdateInboundFees(a, InboundFees(1 msat, 123)) + db.addOrUpdateInboundFees(b, InboundFees(3 msat, 789)) + assert(db.getInboundFees(a).nonEmpty) + assert(db.getInboundFees(b).nonEmpty) + db.unsetInboundFees(a) + assert(db.getInboundFees(a).isEmpty) + assert(db.getInboundFees(b).nonEmpty) + } + } + +} \ No newline at end of file diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/router/Blip18FinalizeRouteSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/router/Blip18FinalizeRouteSpec.scala index e320937424..0287db3db9 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/router/Blip18FinalizeRouteSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/router/Blip18FinalizeRouteSpec.scala @@ -1,3 +1,19 @@ +/* + * Copyright 2025 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package fr.acinq.eclair.router import akka.actor.typed.scaladsl.adapter.ClassicActorRefOps @@ -390,4 +406,4 @@ object Blip18FinalizeRouteSpec { tlvStream = tlvStream ) } -} +} \ No newline at end of file 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 9402593476..db0a89a05c 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 @@ -1,3 +1,19 @@ +/* + * Copyright 2025 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package fr.acinq.eclair.router import com.softwaremill.quicklens.ModifyPimp @@ -2653,4 +2669,4 @@ object Blip18RouteCalculationSpec { assert(routes.map(_.amount).sum == totalAmount, routes) assert(routes.map(_.channelFee(false)).sum <= maxFee, routes) } -} +} \ No newline at end of file diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Fees.scala b/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Fees.scala index c91289b72f..a479fc1b37 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Fees.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Fees.scala @@ -29,19 +29,17 @@ trait Fees { val updateRelayFee: Route = postRequest("updaterelayfee") { implicit t => withNodesIdentifier { nodes => - formFields("feeBaseMsat".as[MilliSatoshi], "feeProportionalMillionths".as[Long], "inboundFeeBaseMsat".as[MilliSatoshi]?, "inboundFeeProportionalMillionths".as[Long]?) { (feeBase, feeProportional, inboundFeeBase_opt, inboundFeeProportional_opt) => + formFields("feeBaseMsat".as[MilliSatoshi], "feeProportionalMillionths".as[Long], "inboundFeeBaseMsat".as[MilliSatoshi]?, "inboundFeeProportionalMillionths".as[Long]?, "unsetInboundFees".as[Boolean]?) { (feeBase, feeProportional, inboundFeeBase_opt, inboundFeeProportional_opt, unsetInboundFees_opt) => if (feeBase.toLong < 0 || feeProportional < 0) { reject(ValidationRejection("Fees must be nonnegative")) } else if (inboundFeeBase_opt.isEmpty && inboundFeeProportional_opt.isDefined) { reject(ValidationRejection("Inbound fee base is required")) } else if (inboundFeeBase_opt.isDefined && inboundFeeProportional_opt.isEmpty) { reject(ValidationRejection("Inbound fee proportional millionths is required")) - } else if (!inboundFeeBase_opt.forall(value => value.toLong >= Int.MinValue && value.toLong <= 0)) { - reject(ValidationRejection(s"Inbound fee base must be must be in the range from ${Int.MinValue} to 0")) - } else if (!inboundFeeProportional_opt.forall(value => value >= Int.MinValue && value <= 0)) { - reject(ValidationRejection(s"Inbound fee proportional millionths must be in the range from ${Int.MinValue} to 0")) + } else if (unsetInboundFees_opt.isDefined && (inboundFeeBase_opt.isDefined || inboundFeeProportional_opt.isDefined)) { + reject(ValidationRejection("--unsetInboundFees cannot be used with --inboundFeeBaseMsat and --inboundFeeProportionalMillionths")) } else { - complete(eclairApi.updateRelayFee(nodes, feeBase, feeProportional, inboundFeeBase_opt, inboundFeeProportional_opt)) + complete(eclairApi.updateRelayFee(nodes, feeBase, feeProportional, inboundFeeBase_opt, inboundFeeProportional_opt, unsetInboundFees_opt.getOrElse(false))) } } } From 8a7f23e6b62e997a0a641d88b7c2fb365ba176f6 Mon Sep 17 00:00:00 2001 From: rorp Date: Sun, 5 Jul 2026 20:49:12 -0700 Subject: [PATCH 09/11] Cleanup --- .../src/main/scala/fr/acinq/eclair/db/InboundFeesDb.scala | 4 ++-- .../fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala | 2 +- .../src/main/scala/fr/acinq/eclair/api/handlers/Fees.scala | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/db/InboundFeesDb.scala b/eclair-core/src/main/scala/fr/acinq/eclair/db/InboundFeesDb.scala index 844e1e3d16..7bff0fd222 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/db/InboundFeesDb.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/db/InboundFeesDb.scala @@ -19,7 +19,7 @@ package fr.acinq.eclair.db import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.eclair.payment.relay.Relayer.InboundFees -/** The PeersDb contains information about our direct peers, with whom we have or had channels. */ +/** The InboundFeesDb contains the bLIP-18 inbound fees we advertise per peer, with whom we have or had channels. */ trait InboundFeesDb { def addOrUpdateInboundFees(nodeId: PublicKey, fees: InboundFees): Unit @@ -28,4 +28,4 @@ trait InboundFeesDb { def unsetInboundFees(nodeId: PublicKey): Unit -} \ No newline at end of file +} 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 e0ab9df310..50732e58a1 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 @@ -776,7 +776,7 @@ case class ChannelUpdate(signature: ByteVector64, def relayFees: Relayer.RelayFees = Relayer.RelayFees(feeBase = feeBaseMsat, feeProportionalMillionths = feeProportionalMillionths) - val blip18InboundFees_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-node/src/main/scala/fr/acinq/eclair/api/handlers/Fees.scala b/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Fees.scala index a479fc1b37..0ed1cfa879 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Fees.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Fees.scala @@ -36,7 +36,7 @@ trait Fees { reject(ValidationRejection("Inbound fee base is required")) } else if (inboundFeeBase_opt.isDefined && inboundFeeProportional_opt.isEmpty) { reject(ValidationRejection("Inbound fee proportional millionths is required")) - } else if (unsetInboundFees_opt.isDefined && (inboundFeeBase_opt.isDefined || inboundFeeProportional_opt.isDefined)) { + } else if (unsetInboundFees_opt.contains(true) && (inboundFeeBase_opt.isDefined || inboundFeeProportional_opt.isDefined)) { reject(ValidationRejection("--unsetInboundFees cannot be used with --inboundFeeBaseMsat and --inboundFeeProportionalMillionths")) } else { complete(eclairApi.updateRelayFee(nodes, feeBase, feeProportional, inboundFeeBase_opt, inboundFeeProportional_opt, unsetInboundFees_opt.getOrElse(false))) From f1be4db9ae44093b29ce03d29283fd4a1500109b Mon Sep 17 00:00:00 2001 From: rorp Date: Mon, 6 Jul 2026 08:53:18 -0700 Subject: [PATCH 10/11] Stop advertising stale bLIP-18 inbound fees --- .../fr/acinq/eclair/channel/Helpers.scala | 5 +- .../fr/acinq/eclair/channel/fsm/Channel.scala | 15 ++- .../fr/acinq/eclair/channel/RestoreSpec.scala | 96 ++++++++++++++++++- 3 files changed, 104 insertions(+), 12 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala index 5343dcb52b..952d1399b9 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala @@ -352,7 +352,10 @@ object Helpers { isPrivate = !commitments.announceChannel, enable = enable, timestamp = TimestampSecond.now(), - inboundFees_opt = inboundFees_opt + // We never advertise inbound fees when bLIP-18 support is disabled: the relay wouldn't honour them, so keeping + // them in our channel_update (e.g. from before the feature was disabled) would give senders a fee they cannot + // actually use. This gate applies to all channel_update paths (fee updates, periodic refresh, disable, etc.). + inboundFees_opt = if (nodeParams.routerConf.blip18.enableInboundFees) inboundFees_opt else None ) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala index c1337b8cae..e433df45d5 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala @@ -469,7 +469,9 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall inboundFees_opt != normal.channelUpdate.blip18InboundFees_opt || nodeParams.channelConf.expiryDelta != normal.channelUpdate.cltvExpiryDelta) { log.debug("refreshing channel_update due to configuration changes") - self ! CMD_UPDATE_RELAY_FEE(ActorRef.noSender, fees.feeBase, fees.feeProportionalMillionths, inboundFees_opt.map(_.feeBase), inboundFees_opt.map(_.feeProportionalMillionths)) + // The database is the reference for inbound fees: if it doesn't contain any for that peer, we must stop + // advertising them, otherwise this refresh would preserve them and fire again at every restart. + self ! CMD_UPDATE_RELAY_FEE(ActorRef.noSender, fees.feeBase, fees.feeProportionalMillionths, inboundFees_opt.map(_.feeBase), inboundFees_opt.map(_.feeProportionalMillionths), unsetInboundFees = inboundFees_opt.isEmpty) } } // we need to periodically re-send channel updates, otherwise channel will be considered stale and get pruned by network @@ -3362,16 +3364,11 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall /** * Compute the bLIP-18 inbound fees to advertise for a relay fee update. When inbound fees are omitted from the command - * we preserve the ones currently advertised in our channel_update. When bLIP-18 support is disabled we never advertise - * inbound fees: this ensures a node that disables the feature actually stops advertising (and honouring) inbound fees, - * instead of keeping a stale discount forever (which would also make the restore-time refresh loop indefinitely). + * we preserve the ones currently advertised in our channel_update. Note that when bLIP-18 support is disabled, inbound + * fees are stripped from the resulting channel_update by [[Helpers.channelUpdate]]. */ private def updatedInboundFees(c: CMD_UPDATE_RELAY_FEE, d: DATA_NORMAL): Option[InboundFees] = { - if (nodeParams.routerConf.blip18.enableInboundFees) { - InboundFees.fromOptions(c.inboundFeeBase_opt, c.inboundFeeProportionalMillionths_opt).orElse(if (c.unsetInboundFees) None else d.channelUpdate.blip18InboundFees_opt) - } else { - None - } + InboundFees.fromOptions(c.inboundFeeBase_opt, c.inboundFeeProportionalMillionths_opt).orElse(if (c.unsetInboundFees) None else d.channelUpdate.blip18InboundFees_opt) } private def handleUpdateRelayFeeDisconnected(c: CMD_UPDATE_RELAY_FEE, d: DATA_NORMAL) = { diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/RestoreSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/RestoreSpec.scala index 1db149c488..5242453820 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/RestoreSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/RestoreSpec.scala @@ -6,12 +6,13 @@ import akka.testkit.{TestActor, TestFSMRef, TestProbe} import com.softwaremill.quicklens.ModifyPimp import fr.acinq.eclair.TestConstants.{Alice, Bob} import fr.acinq.eclair.channel.fsm.Channel -import fr.acinq.eclair.channel.states.ChannelStateTestsBase import fr.acinq.eclair.channel.states.ChannelStateTestsBase.FakeTxPublisherFactory +import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags} +import fr.acinq.eclair.payment.relay.Relayer.InboundFees import fr.acinq.eclair.router.Announcements import fr.acinq.eclair.wire.protocol.{ChannelReady, ChannelReestablish, ChannelUpdate, Init} import fr.acinq.eclair.{TestKitBaseClass, _} -import org.scalatest.Outcome +import org.scalatest.{Outcome, Tag} import org.scalatest.funsuite.FixtureAnyFunSuiteLike import scala.concurrent.duration._ @@ -162,4 +163,95 @@ class RestoreSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Chan } } + test("restore channel with inbound fees removed from database", Tag(ChannelStateTestsTags.Blip18InboundFees)) { f => + import f._ + val sender = TestProbe() + + // alice advertises an inbound fee discount for the channel (keeping the same outbound fees) + val fees = Alice.nodeParams.relayParams.privateChannelFees + sender.send(alice, CMD_UPDATE_RELAY_FEE(ActorRef.noSender, fees.feeBase, fees.feeProportionalMillionths, Some(-100 msat), Some(-50))) + sender.expectMsgType[RES_SUCCESS[CMD_UPDATE_RELAY_FEE]] + awaitCond(alice.stateData.asInstanceOf[DATA_NORMAL].channelUpdate.blip18InboundFees_opt.contains(InboundFees(-100 msat, -50))) + val oldStateData = alice.stateData.asInstanceOf[DATA_NORMAL] + + val channelUpdateListener = { + val listener = TestProbe() + system.eventStream.subscribe(listener.ref, classOf[ChannelUpdateParametersChanged]) + aliceChannelUpdateListener(listener) + } + + // we simulate a disconnection + sender.send(alice, INPUT_DISCONNECTED) + sender.send(bob, INPUT_DISCONNECTED) + awaitCond(alice.stateName == OFFLINE) + awaitCond(bob.stateName == OFFLINE) + + // and we terminate Alice + alice.stop() + + // we restart Alice with bLIP-18 still enabled, but no inbound fees in the database for that peer: the database is + // the reference, so the restore-time refresh must stop advertising the previous inbound fees (instead of + // preserving them, which would fire the refresh again at every restart without ever converging) + val newConfig = Alice.nodeParams.modify(_.routerConf.blip18.enableInboundFees).setTo(true) + assert(newConfig.db.inboundFees.getInboundFees(Bob.nodeParams.nodeId).isEmpty) + val newAlice: TestFSMRef[ChannelState, ChannelData, Channel] = TestFSMRef(new Channel(newConfig, Alice.channelKeys(), aliceWallet, Bob.nodeParams.nodeId, alice2blockchain.ref, alice2relayer.ref, FakeTxPublisherFactory(alice2blockchain)), alicePeer.ref) + newAlice ! INPUT_RESTORED(oldStateData) + + val cup = channelUpdateListener.expectMsgType[ChannelUpdateParametersChanged] + assert(cup.channelUpdate.blip18InboundFees_opt.isEmpty) + awaitCond(newAlice.stateData.asInstanceOf[DATA_NORMAL].channelUpdate.blip18InboundFees_opt.isEmpty) + } + + test("restore channel with bLIP-18 disabled and stale inbound fees", Tag(ChannelStateTestsTags.Blip18InboundFees)) { f => + import f._ + val sender = TestProbe() + + // alice advertises an inbound fee discount for the channel while bLIP-18 support is enabled + val fees = Alice.nodeParams.relayParams.privateChannelFees + sender.send(alice, CMD_UPDATE_RELAY_FEE(ActorRef.noSender, fees.feeBase, fees.feeProportionalMillionths, Some(-100 msat), Some(-50))) + sender.expectMsgType[RES_SUCCESS[CMD_UPDATE_RELAY_FEE]] + awaitCond(alice.stateData.asInstanceOf[DATA_NORMAL].channelUpdate.blip18InboundFees_opt.contains(InboundFees(-100 msat, -50))) + // the channel is private, so the refreshed channel_update is sent directly to the peer + assert(alice2bob.expectMsgType[ChannelUpdate].blip18InboundFees_opt.contains(InboundFees(-100 msat, -50))) + val oldStateData = alice.stateData.asInstanceOf[DATA_NORMAL] + + val channelUpdateListener = { + val listener = TestProbe() + system.eventStream.subscribe(listener.ref, classOf[ChannelUpdateParametersChanged]) + aliceChannelUpdateListener(listener) + } + + // we simulate a disconnection + sender.send(alice, INPUT_DISCONNECTED) + sender.send(bob, INPUT_DISCONNECTED) + awaitCond(alice.stateName == OFFLINE) + awaitCond(bob.stateName == OFFLINE) + + // and we terminate Alice + alice.stop() + + // we restart Alice with bLIP-18 disabled and without the restore-time fee reset: even then, the first + // channel_update refresh must drop the previously advertised inbound fees, since the relay no longer honours them + val newConfig = Alice.nodeParams.modify(_.relayParams.resetExistingChannels).setTo(false) + assert(!newConfig.routerConf.blip18.enableInboundFees) + val newAlice: TestFSMRef[ChannelState, ChannelData, Channel] = TestFSMRef(new Channel(newConfig, Alice.channelKeys(), aliceWallet, Bob.nodeParams.nodeId, alice2blockchain.ref, alice2relayer.ref, FakeTxPublisherFactory(alice2blockchain)), alicePeer.ref) + newAlice ! INPUT_RESTORED(oldStateData) + channelUpdateListener.expectNoMessage(100 millis) + + alice2bob.ignoreMsg { case _: ChannelReady => true } + bob2alice.ignoreMsg { case _: ChannelReady => true } + + newAlice ! INPUT_RECONNECTED(alice2bob.ref, aliceInit, bobInit) + bob ! INPUT_RECONNECTED(bob2alice.ref, bobInit, aliceInit) + alice2bob.expectMsgType[ChannelReestablish] + bob2alice.expectMsgType[ChannelReestablish] + alice2bob.forward(bob) + bob2alice.forward(newAlice) + awaitCond(newAlice.stateName == NORMAL) + + // on reconnection the channel_update is regenerated (private channels re-broadcast immediately): the stale + // inbound fees must be gone + awaitCond(newAlice.stateData.asInstanceOf[DATA_NORMAL].channelUpdate.blip18InboundFees_opt.isEmpty) + } + } From c9599b0a0e2b3ffe023bc3d010946d73883459a0 Mon Sep 17 00:00:00 2001 From: rorp Date: Mon, 6 Jul 2026 15:25:45 -0700 Subject: [PATCH 11/11] Fix bLIP-18 relay edge cases and simplify plumbing --- .../main/scala/fr/acinq/eclair/Eclair.scala | 2 +- .../payment/offer/DefaultOfferHandler.scala | 2 +- .../eclair/payment/relay/ChannelRelay.scala | 42 +++++++++---- .../eclair/payment/relay/ChannelRelayer.scala | 19 +----- .../send/MultiPartPaymentLifecycle.scala | 2 +- .../payment/send/PaymentLifecycle.scala | 12 ++-- .../scala/fr/acinq/eclair/router/Graph.scala | 60 ++++++++----------- .../eclair/router/RouteCalculation.scala | 16 ++--- .../scala/fr/acinq/eclair/router/Router.scala | 15 ++--- .../payment/relay/ChannelRelayerSpec.scala | 37 ++++++++++++ .../router/Blip18FinalizeRouteSpec.scala | 20 +++---- 11 files changed, 129 insertions(+), 98 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala index 4494b8e9aa..ebfe878214 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala @@ -508,7 +508,7 @@ class EclairImpl(val appKit: Kit) extends Eclair with Logging with SpendFromChan for { ignoredChannels <- getChannelDescs(ignoreShortChannelIds.toSet) ignore = Ignore(ignoreNodeIds.toSet, ignoredChannels) - response <- appKit.router.toTyped.ask[PaymentRouteResponse](replyTo => RouteRequest(replyTo, sourceNodeId, target, routeParams1, ignore, blip18 = appKit.nodeParams.routerConf.blip18)).flatMap { + response <- appKit.router.toTyped.ask[PaymentRouteResponse](replyTo => RouteRequest(replyTo, sourceNodeId, target, routeParams1, ignore)).flatMap { case r: RouteResponse => Future.successful(r) case PaymentRouteNotFound(error) => Future.failed(error) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/DefaultOfferHandler.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/DefaultOfferHandler.scala index 50ee71e842..32393813d3 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/DefaultOfferHandler.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/DefaultOfferHandler.scala @@ -89,7 +89,7 @@ object DefaultOfferHandler { maxRouteLength = nodeParams.offersConfig.paymentPathLength, maxCltv = nodeParams.offersConfig.paymentPathCltvExpiryDelta )) - router ! BlindedRouteRequest(context.messageAdapter(WrappedRouteResponse), blindedPathFirstNodeId, nodeParams.nodeId, invoiceRequest.amount, routeParams, nodeParams.offersConfig.paymentPathCount, blip18 = nodeParams.routerConf.blip18) + router ! BlindedRouteRequest(context.messageAdapter(WrappedRouteResponse), blindedPathFirstNodeId, nodeParams.nodeId, invoiceRequest.amount, routeParams, nodeParams.offersConfig.paymentPathCount) waitForRoute(nodeParams, replyTo, invoiceRequest, blindedPathFirstNodeId, context) } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelay.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelay.scala index 34f0e6efe8..1d470e8aa1 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelay.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelay.scala @@ -36,7 +36,7 @@ import fr.acinq.eclair.reputation.ReputationRecorder.GetConfidence import fr.acinq.eclair.wire.protocol.FailureMessageCodecs.createBadOnionFailure import fr.acinq.eclair.wire.protocol.PaymentOnion.IntermediatePayload import fr.acinq.eclair.wire.protocol._ -import fr.acinq.eclair.{EncodedNodeId, Features, InitFeature, Logs, NodeParams, TimestampMilli, TimestampSecond, channel, totalFee} +import fr.acinq.eclair.{EncodedNodeId, Features, InitFeature, Logs, MilliSatoshiLong, NodeParams, TimestampMilli, TimestampSecond, channel, totalFee} import java.util.UUID import java.util.concurrent.TimeUnit @@ -70,8 +70,7 @@ object ChannelRelay { relayId: UUID, r: IncomingPaymentPacket.ChannelRelayPacket, incomingChannelOccupancy: Double, - inboundFees_opt: Option[InboundFees], - prevInboundFees_opt: Option[Option[InboundFees]]): Behavior[Command] = + incomingChannel_opt: Option[OutgoingChannel]): Behavior[Command] = Behaviors.setup { context => Behaviors.withMdc(Logs.mdc( category_opt = Some(Logs.LogCategory.PAYMENT), @@ -98,7 +97,7 @@ object ChannelRelay { } Behaviors.receiveMessagePartial { case WrappedReputationScore(score) => - new ChannelRelay(nodeParams, register, channels, r, upstream, score, inboundFees_opt, prevInboundFees_opt, context).start() + new ChannelRelay(nodeParams, register, channels, r, upstream, score, incomingChannel_opt, context).start() } } } @@ -145,12 +144,33 @@ class ChannelRelay private(nodeParams: NodeParams, r: IncomingPaymentPacket.ChannelRelayPacket, upstream: Upstream.Hot.Channel, reputationScore: Reputation.Score, - inboundFees_opt: Option[InboundFees], - prevInboundFees_opt: Option[Option[InboundFees]], + incomingChannel_opt: Option[OutgoingChannel], context: ActorContext[ChannelRelay.Command]) { import ChannelRelay._ + /** + * bLIP-18 inbound fees advertised in our channel_update for the incoming channel apply to this relay: senders take + * them into account when computing the fee they pay for our hop. We accept: + * - the inbound fees advertised in our current channel_update for the incoming channel + * - if that channel_update was created recently, the previously advertised inbound fees (senders may still be using + * them): we tolerate them during the enforcement delay, just like we do for outbound fees + * - when bLIP-18 is disabled, the plain outbound fee: our channel_update may still advertise inbound fees from + * before the feature was disabled, but we shouldn't enforce a surcharge we're not configured to collect (and + * since it's enough to match any accepted combination, still accepting the advertised inbound fees only makes + * the check more permissive, closing the window where a stale discount would reject correctly-paying senders). + */ + private val acceptableInboundFees: Seq[Option[InboundFees]] = incomingChannel_opt match { + case Some(incomingChannel) => + val currentInboundFees_opt = incomingChannel.channelUpdate.blip18InboundFees_opt + val prevInboundFees_opt = incomingChannel.prevChannelUpdate + .filter(_ => TimestampSecond.now() - incomingChannel.channelUpdate.timestamp <= nodeParams.relayParams.enforcementDelay) + .map(_.blip18InboundFees_opt) + val noInboundFees_opt = if (nodeParams.routerConf.blip18.enableInboundFees) None else Some(None) + (Seq(currentInboundFees_opt) ++ prevInboundFees_opt ++ noInboundFees_opt).distinct + case None => Seq(None) + } + private val forwardFailureAdapter = context.messageAdapter[Register.ForwardFailure[CMD_ADD_HTLC]](WrappedForwardFailure) private val addResponseAdapter = context.messageAdapter[CommandResponse[CMD_ADD_HTLC]](WrappedAddResponse) private val forwardNodeIdFailureAdapter = context.messageAdapter[Register.ForwardNodeIdFailure[Peer.ProposeOnTheFlyFunding]](_ => WrappedOnTheFlyFundingResponse(Peer.ProposeOnTheFlyFundingResponse.NotAvailable("peer not found"))) @@ -439,13 +459,15 @@ class ChannelRelay private(nodeParams: NodeParams, val htlcMinimumOk = update.htlcMinimumMsat <= r.amountToForward || prevUpdate_opt.exists(_.htlcMinimumMsat <= r.amountToForward) val expiryDeltaOk = update.cltvExpiryDelta <= r.expiryDelta || prevUpdate_opt.exists(_.cltvExpiryDelta <= r.expiryDelta) // The fee is sufficient if it covers any accepted combination of outbound relay fees (current or, within the - // enforcement delay, previous outgoing channel_update) and inbound fees (current or, within the enforcement delay, - // previously advertised inbound fees on the incoming channel). - val acceptableInboundFees: Seq[Option[InboundFees]] = (inboundFees_opt +: prevInboundFees_opt.toSeq).distinct + // enforcement delay, previous outgoing channel_update) and inbound fees (see acceptableInboundFees). + // If the incoming channel is unknown (transient, e.g. HTLCs replayed after a restart before we processed the + // corresponding LocalChannelUpdate) and bLIP-18 is enabled, we may have advertised an inbound fee discount that + // we cannot recover: rather than rejecting senders that correctly paid the advertised fee, we accept the relay + // as long as we're not losing money. val feesOk = acceptableInboundFees.exists { inbound => totalFee(r.amountToForward, update.relayFees, inbound) <= r.relayFeeMsat || prevUpdate_opt.exists(u => totalFee(r.amountToForward, u.relayFees, inbound) <= r.relayFeeMsat) - } + } || (incomingChannel_opt.isEmpty && nodeParams.routerConf.blip18.enableInboundFees && 0.msat <= r.relayFeeMsat) if (!htlcMinimumOk) { Some(makeCmdFailHtlc(r.add.id, AmountBelowMinimum(r.amountToForward, Some(update)))) } else if (!expiryDeltaOk) { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelayer.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelayer.scala index 5cc040566a..c9475a1844 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelayer.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/ChannelRelayer.scala @@ -25,7 +25,7 @@ import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.eclair.channel._ import fr.acinq.eclair.payment.IncomingPaymentPacket import fr.acinq.eclair.reputation.ReputationRecorder -import fr.acinq.eclair.{Logs, NodeParams, ShortChannelId, SubscriptionsComplete, TimestampSecond} +import fr.acinq.eclair.{Logs, NodeParams, ShortChannelId, SubscriptionsComplete} import java.util.UUID import scala.collection.mutable @@ -85,23 +85,8 @@ object ChannelRelayer { } // bLIP-18 inbound fees advertised in our channel_update for the incoming channel apply to this relay. val incomingChannel_opt = channels.get(channelRelayPacket.add.channelId) - val inboundFees_opt = if (nodeParams.routerConf.blip18.enableInboundFees) { - incomingChannel_opt.flatMap(_.channelUpdate.blip18InboundFees_opt) - } else { - None - } - // If we recently updated the inbound fees of the incoming channel, senders may still be using the previously - // advertised inbound fees: we tolerate them during the enforcement delay, just like we do for outbound fees. - val prevInboundFees_opt = if (nodeParams.routerConf.blip18.enableInboundFees) { - incomingChannel_opt - .filter(c => TimestampSecond.now() - c.channelUpdate.timestamp <= nodeParams.relayParams.enforcementDelay) - .flatMap(_.prevChannelUpdate) - .map(_.blip18InboundFees_opt) - } else { - None - } context.log.debug(s"spawning a new handler with relayId=$relayId to nextNodeId={} with channels={}", nextNodeId_opt.getOrElse(""), nextChannels.keys.mkString(",")) - context.spawn(ChannelRelay.apply(nodeParams, register, reputationRecorder_opt, nextChannels, originNode, relayId, channelRelayPacket, incomingChannelOccupancy, inboundFees_opt, prevInboundFees_opt), name = relayId.toString) + context.spawn(ChannelRelay.apply(nodeParams, register, reputationRecorder_opt, nextChannels, originNode, relayId, channelRelayPacket, incomingChannelOccupancy, incomingChannel_opt), name = relayId.toString) Behaviors.same case GetOutgoingChannels(replyTo, Relayer.GetOutgoingChannels(enabledOnly)) => diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/MultiPartPaymentLifecycle.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/MultiPartPaymentLifecycle.scala index f9662e0b68..1937346aa9 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/MultiPartPaymentLifecycle.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/MultiPartPaymentLifecycle.scala @@ -372,7 +372,7 @@ object MultiPartPaymentLifecycle { case class PaymentSucceeded(request: SendMultiPartPayment, preimage: ByteVector32, parts: Seq[PaymentPart], pending: Set[UUID], remainingAttribution_opt: Option[ByteVector]) extends Data private def createRouteRequest(replyTo: ActorRef, nodeParams: NodeParams, routeParams: RouteParams, d: PaymentProgress, cfg: SendPaymentConfig): RouteRequest = { - RouteRequest(replyTo.toTyped, nodeParams.nodeId, d.request.recipient, routeParams, d.ignore, allowMultiPart = true, d.pending.values.toSeq, Some(cfg.paymentContext), blip18 = nodeParams.routerConf.blip18) + RouteRequest(replyTo.toTyped, nodeParams.nodeId, d.request.recipient, routeParams, d.ignore, allowMultiPart = true, d.pending.values.toSeq, Some(cfg.paymentContext)) } private def createChildPayment(replyTo: ActorRef, route: Route, request: SendMultiPartPayment): SendPaymentToRoute = { 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 81b9ff7e01..5757a07f8e 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 @@ -55,7 +55,7 @@ class PaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, router: A case Event(request: SendPaymentToRoute, WaitingForRequest) => log.debug("sending {} to route {}", request.amount, request.printRoute()) request.route.fold( - hops => router ! FinalizeRoute(self, hops, request.recipient.extraEdges, paymentContext = Some(cfg.paymentContext), blip18 = nodeParams.routerConf.blip18), + hops => router ! FinalizeRoute(self, hops, request.recipient.extraEdges, paymentContext = Some(cfg.paymentContext)), route => self ! RouteResponse(route :: Nil) ) if (cfg.storeInDb) { @@ -65,7 +65,7 @@ class PaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, router: A case Event(request: SendPaymentToNode, WaitingForRequest) => log.debug("sending {} to {}", request.amount, request.recipient.nodeId) - router ! RouteRequest(self, nodeParams.nodeId, request.recipient, request.routeParams, paymentContext = Some(cfg.paymentContext), blip18 = nodeParams.routerConf.blip18) + router ! RouteRequest(self, nodeParams.nodeId, request.recipient, request.routeParams, paymentContext = Some(cfg.paymentContext)) if (cfg.storeInDb) { paymentsDb.addOutgoingPayment(OutgoingPayment(id, cfg.parentId, cfg.externalId, paymentHash, cfg.paymentType, request.amount, request.recipient.totalAmount, request.recipient.nodeId, TimestampMilli.now(), cfg.invoice, cfg.payerKey_opt, OutgoingPaymentStatus.Pending)) } @@ -161,7 +161,7 @@ class PaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, router: A data.request match { case request: SendPaymentToNode => val ignore1 = PaymentFailure.updateIgnored(failure, data.ignore) - router ! RouteRequest(self, nodeParams.nodeId, data.recipient, request.routeParams, ignore1, paymentContext = Some(cfg.paymentContext), blip18 = nodeParams.routerConf.blip18) + router ! RouteRequest(self, nodeParams.nodeId, data.recipient, request.routeParams, ignore1, paymentContext = Some(cfg.paymentContext)) goto(WAITING_FOR_ROUTE) using WaitingForRoute(data.request, data.failures :+ failure, ignore1) case _: SendPaymentToRoute => log.error("unexpected retry during SendPaymentToRoute") @@ -271,7 +271,7 @@ class PaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, router: A log.error("unexpected retry during SendPaymentToRoute") stop(FSM.Normal) case request: SendPaymentToNode => - router ! RouteRequest(self, nodeParams.nodeId, recipient1, request.routeParams, ignore1, paymentContext = Some(cfg.paymentContext), blip18 = nodeParams.routerConf.blip18) + router ! RouteRequest(self, nodeParams.nodeId, recipient1, request.routeParams, ignore1, paymentContext = Some(cfg.paymentContext)) goto(WAITING_FOR_ROUTE) using WaitingForRoute(request.copy(recipient = recipient1), failures :+ failure, ignore1) } } else { @@ -282,7 +282,7 @@ class PaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, router: A log.error("unexpected retry during SendPaymentToRoute") stop(FSM.Normal) case request: SendPaymentToNode => - router ! RouteRequest(self, nodeParams.nodeId, recipient, request.routeParams, ignore + nodeId, paymentContext = Some(cfg.paymentContext), blip18 = nodeParams.routerConf.blip18) + router ! RouteRequest(self, nodeParams.nodeId, recipient, request.routeParams, ignore + nodeId, paymentContext = Some(cfg.paymentContext)) goto(WAITING_FOR_ROUTE) using WaitingForRoute(request, failures :+ failure, ignore + nodeId) } } @@ -296,7 +296,7 @@ class PaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, router: A log.error("unexpected retry during SendPaymentToRoute") stop(FSM.Normal) case request: SendPaymentToNode => - router ! RouteRequest(self, nodeParams.nodeId, recipient, request.routeParams, ignore1, paymentContext = Some(cfg.paymentContext), blip18 = nodeParams.routerConf.blip18) + router ! RouteRequest(self, nodeParams.nodeId, recipient, request.routeParams, ignore1, paymentContext = Some(cfg.paymentContext)) goto(WAITING_FOR_ROUTE) using WaitingForRoute(request, failures :+ failure, ignore1) } case Right(e@Sphinx.DecryptedFailurePacket(nodeId, _, failureMessage)) => 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 ceff4a4c6f..ad9e003431 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 @@ -88,10 +88,9 @@ object Graph { * @param prev weight of the rest of the path * @param currentBlockHeight the height of the chain tip (latest block). * @param includeLocalChannelCost if the path is for relaying and we need to include the cost of the local channel - * @param inbound_opt inbound fees - * @param blip18 bLIP-18 inbound fees settings + * @param inbound_opt bLIP-18 inbound fees charged by the edge's target for relaying the payment */ - def addEdgeWeight(sender: PublicKey, edge: GraphEdge, balance: BalanceEstimate, prev: RichWeight, currentBlockHeight: BlockHeight, includeLocalChannelCost: Boolean, inbound_opt: Option[Relayer.InboundFees], blip18: Blip18Params): RichWeight + def addEdgeWeight(sender: PublicKey, edge: GraphEdge, balance: BalanceEstimate, prev: RichWeight, currentBlockHeight: BlockHeight, includeLocalChannelCost: Boolean, inbound_opt: Option[Relayer.InboundFees]): RichWeight } /** @@ -105,9 +104,9 @@ object Graph { * @param usePastRelaysData use data from past relays to estimate the balance of the channels */ case class HeuristicsConstants(lockedFundsRisk: Double, failureFees: RelayFees, hopFees: RelayFees, useLogProbability: Boolean, usePastRelaysData: Boolean) extends WeightRatios[PaymentPathWeight] { - override def addEdgeWeight(sender: PublicKey, edge: GraphEdge, balance: BalanceEstimate, prev: PaymentPathWeight, currentBlockHeight: BlockHeight, includeLocalChannelCost: Boolean, inbound_opt: Option[Relayer.InboundFees], blip18: Blip18Params): PaymentPathWeight = { + override def addEdgeWeight(sender: PublicKey, edge: GraphEdge, balance: BalanceEstimate, prev: PaymentPathWeight, currentBlockHeight: BlockHeight, includeLocalChannelCost: Boolean, inbound_opt: Option[Relayer.InboundFees]): PaymentPathWeight = { val isSenderEdge = edge.desc.a == sender && !includeLocalChannelCost - val (totalAmount, edgeOutboundFee) = addEdgeFeesWithInbound(edge, prev, inbound_opt, blip18, isSenderEdge) + val (totalAmount, edgeOutboundFee) = addEdgeFeesWithInbound(edge, prev, inbound_opt, isSenderEdge) val fee = totalAmount - prev.amount val totalFees = prev.fees + fee val totalCltv = prev.cltv + edge.params.cltvExpiryDelta @@ -155,7 +154,7 @@ object Graph { require(ageFactor >= 0.0, "ratio-channel-age must be nonnegative") require(capacityFactor >= 0.0, "ratio-channel-capacity must be nonnegative") - override def addEdgeWeight(sender: PublicKey, edge: GraphEdge, balance: BalanceEstimate, prev: MessagePathWeight, currentBlockHeight: BlockHeight, includeLocalChannelCost: Boolean, inbound_opt: Option[Relayer.InboundFees], blip18: Blip18Params): MessagePathWeight = { + override def addEdgeWeight(sender: PublicKey, edge: GraphEdge, balance: BalanceEstimate, prev: MessagePathWeight, currentBlockHeight: BlockHeight, includeLocalChannelCost: Boolean, inbound_opt: Option[Relayer.InboundFees]): MessagePathWeight = { import RoutingHeuristics._ // Every edge is weighted by funding block height where older blocks add less weight. The window considered is 1 year. @@ -376,7 +375,7 @@ object Graph { if (!excludeEdge) { // 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 = wr.addEdgeWeight(sourceNode, edge, g.balances.get(edge), current.weight, currentBlockHeight, includeLocalChannelCost, inboundFees_opt, blip18) + val neighborWeight = wr.addEdgeWeight(sourceNode, edge, g.balances.get(edge), current.weight, currentBlockHeight, includeLocalChannelCost, inboundFees_opt) if (boundaries(neighborWeight)) { val previousNeighborWeight = bestWeights.get(neighbor) // if this path between neighbor and the target has a shorter distance than previously known, we select it @@ -502,39 +501,30 @@ object Graph { * * @param edge the edge we want to cross (A→B) * @param prev weight of the rest of the path (from B toward target) - * @param inbound_opt B's inbound fees for traffic from A - * @param blip18 bLIP-18 inbound fees settings + * @param inbound_opt B's inbound fees for traffic from A (None when bLIP-18 is disabled, which reduces to the + * plain outbound fee computation) * @param isSenderEdge true if A is the payment sender (no outbound fee charged) * @return (totalAmount, outboundFee) where totalAmount is what A needs to receive and * outboundFee is A's outbound fee (stored for next iteration's inbound fee capping) */ - private def addEdgeFeesWithInbound(edge: GraphEdge, prev: PaymentPathWeight, inbound_opt: Option[Relayer.InboundFees], blip18: Blip18Params, isSenderEdge: Boolean): (MilliSatoshi, MilliSatoshi) = { - if (!blip18.enableInboundFees) { - if (isSenderEdge) { - (prev.amount, 0 msat) - } else { - val outboundFee = nodeFee(edge.params.relayFees, prev.amount) - (prev.amount + outboundFee, outboundFee) - } - } else { - val inboundFee = inbound_opt - .map(inbound => nodeFee(inbound.feeBase, inbound.feeProportionalMillionths, prev.amount)) - .getOrElse(0 msat) + private def addEdgeFeesWithInbound(edge: GraphEdge, prev: PaymentPathWeight, inbound_opt: Option[Relayer.InboundFees], isSenderEdge: Boolean): (MilliSatoshi, MilliSatoshi) = { + val inboundFee = inbound_opt + .map(inbound => nodeFee(inbound.feeBase, inbound.feeProportionalMillionths, prev.amount)) + .getOrElse(0 msat) - val cappedInboundFee = if (inboundFee.toLong < -prev.outboundFee.toLong) { - MilliSatoshi(-prev.outboundFee.toLong) - } else { - inboundFee - } + val cappedInboundFee = if (inboundFee.toLong < -prev.outboundFee.toLong) { + MilliSatoshi(-prev.outboundFee.toLong) + } else { + inboundFee + } - val amountToSend = prev.amount + cappedInboundFee + val amountToSend = prev.amount + cappedInboundFee - if (isSenderEdge) { - (amountToSend, 0 msat) - } else { - val outboundFee = nodeFee(edge.params.relayFees, amountToSend) - (amountToSend + outboundFee, outboundFee) - } + if (isSenderEdge) { + (amountToSend, 0 msat) + } else { + val outboundFee = nodeFee(edge.params.relayFees, amountToSend) + (amountToSend + outboundFee, outboundFee) } } @@ -572,7 +562,7 @@ object Graph { // Inbound fees never apply to the last edge of the path: the recipient doesn't relay the payment, so it // cannot charge relay fees, regardless of what its channel_update advertises. val inboundFees_opt = if (blip18.enableInboundFees && edge.desc.b != targetNode) getInboundFees(graph, edge) else None - wr.addEdgeWeight(sender, edge, balances.get(edge), prev, currentBlockHeight, includeLocalChannelCost, inboundFees_opt, blip18) + wr.addEdgeWeight(sender, edge, balances.get(edge), prev, currentBlockHeight, includeLocalChannelCost, inboundFees_opt) } } @@ -732,7 +722,7 @@ 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(desc: ChannelDesc): Option[GraphEdge] = getEdge(desc.reversed) def getBackEdge(edge: GraphEdge): Option[GraphEdge] = getBackEdge(edge.desc) 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 3aec902e57..d11410f374 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 @@ -48,7 +48,7 @@ object RouteCalculation { } } - def finalizeRoute(d: Data, localNodeId: PublicKey, fr: FinalizeRoute)(implicit ctx: ActorContext, log: DiagnosticLoggingAdapter): Data = { + def finalizeRoute(d: Data, localNodeId: PublicKey, fr: FinalizeRoute, blip18: Blip18Params)(implicit ctx: ActorContext, log: DiagnosticLoggingAdapter): Data = { def validateMaxRouteFee(route: Route, maxFee_opt: Option[MilliSatoshi]): Try[Route] = { val routeFee = route.channelFee(includeLocalChannelCost = false) maxFee_opt match { @@ -77,8 +77,8 @@ object RouteCalculation { val g = extraEdges.foldLeft(d.graphWithBalances.graph) { case (g: DirectedGraph, e: GraphEdge) => g.addEdge(e) } def finalizeAndReply(amount: MilliSatoshi, edges: Seq[GraphEdge], maxFee_opt: Option[MilliSatoshi]): Unit = { - val hops = Graph.enrichPathWithInboundFees(edges, g, fr.blip18).map(e => ChannelHop(getEdgeRelayScid(d, localNodeId, e), e.desc.a, e.desc.b, e.params)) - val route = validatePositiveInboundFees(Route(amount, hops, None), fr.blip18).flatMap(validateMaxRouteFee(_, maxFee_opt)) + val hops = Graph.enrichPathWithInboundFees(edges, g, blip18).map(e => ChannelHop(getEdgeRelayScid(d, localNodeId, e), e.desc.a, e.desc.b, e.params)) + val route = validatePositiveInboundFees(Route(amount, hops, None), blip18).flatMap(validateMaxRouteFee(_, maxFee_opt)) route match { case Success(r) => fr.replyTo ! RouteResponse(r :: Nil) case Failure(f) => fr.replyTo ! PaymentRouteNotFound(f) @@ -191,7 +191,7 @@ object RouteCalculation { }) } - def handleRouteRequest(d: Data, currentBlockHeight: BlockHeight, r: RouteRequest)(implicit ctx: ActorContext, log: DiagnosticLoggingAdapter): Data = { + def handleRouteRequest(d: Data, currentBlockHeight: BlockHeight, r: RouteRequest, blip18: Blip18Params)(implicit ctx: ActorContext, log: DiagnosticLoggingAdapter): Data = { Logs.withMdc(log)(Logs.mdc( category_opt = Some(LogCategory.PAYMENT), parentPaymentId_opt = r.paymentContext.map(_.parentId), @@ -209,9 +209,9 @@ object RouteCalculation { val tags = TagSet.Empty.withTag(Tags.MultiPart, r.allowMultiPart).withTag(Tags.Amount, Tags.amountBucket(amountToSend)) KamonExt.time(Metrics.FindRouteDuration.withTags(tags.withTag(Tags.NumberOfRoutes, routesToFind.toLong))) { val result = if (r.allowMultiPart) { - findMultiPartRoute(d.graphWithBalances, r.source, targetNodeId, amountToSend, maxFee, extraEdges, ignoredEdges, r.ignore.nodes, r.pendingPayments, r.routeParams, currentBlockHeight, r.blip18) + findMultiPartRoute(d.graphWithBalances, r.source, targetNodeId, amountToSend, maxFee, extraEdges, ignoredEdges, r.ignore.nodes, r.pendingPayments, r.routeParams, currentBlockHeight, blip18) } else { - findRoute(d.graphWithBalances, r.source, targetNodeId, amountToSend, maxFee, routesToFind, extraEdges, ignoredEdges, r.ignore.nodes, r.routeParams, currentBlockHeight, r.blip18) + findRoute(d.graphWithBalances, r.source, targetNodeId, amountToSend, maxFee, routesToFind, extraEdges, ignoredEdges, r.ignore.nodes, r.routeParams, currentBlockHeight, blip18) } result.map(routes => addFinalHop(r.target, routes)) match { case Success(routes) => @@ -239,7 +239,7 @@ object RouteCalculation { } } - def handleBlindedRouteRequest(d: Data, currentBlockHeight: BlockHeight, r: BlindedRouteRequest): Data = { + def handleBlindedRouteRequest(d: Data, currentBlockHeight: BlockHeight, r: BlindedRouteRequest, blip18: Blip18Params): Data = { val maxFee = r.routeParams.getMaxFee(r.amount) val boundaries: PaymentPathWeight => Boolean = { weight => weight.amount - r.amount <= maxFee && @@ -247,7 +247,7 @@ object RouteCalculation { weight.length <= ROUTE_MAX_LENGTH && weight.cltv <= r.routeParams.boundaries.maxCltv } - val routes = Graph.routeBlindingPaths(d.graphWithBalances, r.source, r.target, r.amount, r.ignore.channels, r.ignore.nodes, r.pathsToFind, r.routeParams.heuristics, currentBlockHeight, boundaries, r.blip18) + val routes = Graph.routeBlindingPaths(d.graphWithBalances, r.source, r.target, r.amount, r.ignore.channels, r.ignore.nodes, r.pathsToFind, r.routeParams.heuristics, currentBlockHeight, boundaries, blip18) if (routes.isEmpty) { r.replyTo ! PaymentRouteNotFound(RouteNotFound) } else { 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 be0689c17e..67e95edf7e 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 @@ -241,13 +241,13 @@ class Router(val nodeParams: NodeParams, watcher: typed.ActorRef[ZmqWatcher.Comm stay() case Event(fr: FinalizeRoute, d: Data) => - stay() using RouteCalculation.finalizeRoute(d, nodeParams.nodeId, fr) + stay() using RouteCalculation.finalizeRoute(d, nodeParams.nodeId, fr, nodeParams.routerConf.blip18) case Event(r: RouteRequest, d: Data) => - stay() using RouteCalculation.handleRouteRequest(d, nodeParams.currentBlockHeight, r) + stay() using RouteCalculation.handleRouteRequest(d, nodeParams.currentBlockHeight, r, nodeParams.routerConf.blip18) case Event(r: BlindedRouteRequest, d: Data) => - stay() using RouteCalculation.handleBlindedRouteRequest(d, nodeParams.currentBlockHeight, r) + stay() using RouteCalculation.handleBlindedRouteRequest(d, nodeParams.currentBlockHeight, r, nodeParams.routerConf.blip18) case Event(r: MessageRouteRequest, d: Data) => stay() using RouteCalculation.handleMessageRouteRequest(d, nodeParams.currentBlockHeight, r, nodeParams.routerConf.messageRouteParams) @@ -665,8 +665,7 @@ object Router { ignore: Ignore = Ignore.empty, allowMultiPart: Boolean = false, pendingPayments: Seq[Route] = Nil, - paymentContext: Option[PaymentContext] = None, - blip18: Blip18Params = Blip18Params.disabled) + paymentContext: Option[PaymentContext] = None) case class BlindedRouteRequest(replyTo: typed.ActorRef[PaymentRouteResponse], source: PublicKey, @@ -674,14 +673,12 @@ object Router { amount: MilliSatoshi, routeParams: RouteParams, pathsToFind: Int, - ignore: Ignore = Ignore.empty, - blip18: Blip18Params = Blip18Params.disabled) + ignore: Ignore = Ignore.empty) case class FinalizeRoute(replyTo: typed.ActorRef[PaymentRouteResponse], route: PredefinedRoute, extraEdges: Seq[ExtraEdge] = Nil, - paymentContext: Option[PaymentContext] = None, - blip18: Blip18Params = Blip18Params.disabled) + paymentContext: Option[PaymentContext] = None) sealed trait PostmanRequest diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/ChannelRelayerSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/ChannelRelayerSpec.scala index 58d8550912..80e79308f9 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/ChannelRelayerSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/ChannelRelayerSpec.scala @@ -598,6 +598,43 @@ class ChannelRelayerSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("a expectFwdAdd(register, channelId1, outgoingAmount, outgoingExpiry, outAccountable = false) } + test("relay with a stale inbound fee discount when bLIP-18 is disabled") { f => + import f._ + + // The incoming channel still advertises an inbound fee discount from before bLIP-18 was disabled: senders paying + // the advertised discounted fee must not be rejected. We give that channel a low balance so that it isn't a + // candidate outgoing channel. + val u_in = createLocalUpdate(channelId2, balance = 1_000_000 msat, inboundFees_opt = Some(InboundFees(-1_000 msat, -50))) + val u_out = createLocalUpdate(channelId1) + val payload = ChannelRelay.Standard(realScid1, outgoingAmount, outgoingExpiry, upgradeAccountability = false) + // The relay fee paid by the sender (1 000 msat) is below our outbound fee (2 000 msat), but matches the total fee + // resulting from the advertised inbound discount (-1 500 msat). + val r = createValidIncomingPacket(payload, amountIn = outgoingAmount + 1_000.msat, incomingChannelId = channelId2) + + channelRelayer ! WrappedLocalChannelUpdate(u_in) + channelRelayer ! WrappedLocalChannelUpdate(u_out) + channelRelayer ! Relay(r, TestConstants.Alice.nodeParams.nodeId, 0.1) + receiveConfidence(Reputation.Score.max(accountable = false)) + + expectFwdAdd(register, channelId1, outgoingAmount, outgoingExpiry, outAccountable = false) + } + + test("relay with an inbound fee discount when the incoming channel is unknown", Tag(blip18InboundFees)) { f => + import f._ + + // The incoming channel is not in our map (transient, e.g. HTLCs replayed right after a restart before we processed + // the corresponding LocalChannelUpdate): we may have advertised an inbound fee discount that we cannot recover, so + // we accept the relay as long as we're not losing money. + val u_out = createLocalUpdate(channelId1) + val payload = ChannelRelay.Standard(realScid1, outgoingAmount, outgoingExpiry, upgradeAccountability = false) + val r = createValidIncomingPacket(payload, amountIn = outgoingAmount + 1_000.msat, incomingChannelId = channelId2) + + channelRelayer ! WrappedLocalChannelUpdate(u_out) + channelRelayer ! Relay(r, TestConstants.Alice.nodeParams.nodeId, 0.1) + receiveConfidence(Reputation.Score.max(accountable = false)) + + expectFwdAdd(register, channelId1, outgoingAmount, outgoingExpiry, outAccountable = false) + } test("fail to relay when there is a local error") { f => import f._ diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/router/Blip18FinalizeRouteSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/router/Blip18FinalizeRouteSpec.scala index 0287db3db9..659799f40a 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/router/Blip18FinalizeRouteSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/router/Blip18FinalizeRouteSpec.scala @@ -97,10 +97,10 @@ class Blip18FinalizeRouteSpec extends TestKitBaseClass with AnyFunSuiteLike with val route = PredefinedNodeRoute(100_000 msat, Seq(a, b, c, d)) val probe = TestProbe() val replyTo = probe.ref.toTyped[Router.PaymentRouteResponse] - val fr = Router.FinalizeRoute(replyTo, route, blip18 = Blip18Params(enableInboundFees = true)) + val fr = Router.FinalizeRoute(replyTo, route) - RouteCalculation.finalizeRoute(data, a, fr) + RouteCalculation.finalizeRoute(data, a, fr, Blip18Params(enableInboundFees = true)) probe.expectMsgType[Router.RouteResponse] match { case Router.RouteResponse(routes) => @@ -146,9 +146,9 @@ class Blip18FinalizeRouteSpec extends TestKitBaseClass with AnyFunSuiteLike with val route = PredefinedNodeRoute(100_000 msat, Seq(a, b, c, d)) val probe = TestProbe() val replyTo = probe.ref.toTyped[Router.PaymentRouteResponse] - val fr = Router.FinalizeRoute(replyTo, route, blip18 = Blip18Params.disabled) + val fr = Router.FinalizeRoute(replyTo, route) - RouteCalculation.finalizeRoute(data, a, fr) + RouteCalculation.finalizeRoute(data, a, fr, Blip18Params.disabled) probe.expectMsgType[Router.RouteResponse] match { case Router.RouteResponse(routes) => @@ -195,9 +195,9 @@ class Blip18FinalizeRouteSpec extends TestKitBaseClass with AnyFunSuiteLike with val route = PredefinedChannelRoute(100_000 msat, e, Seq(ShortChannelId(10L), ShortChannelId(11L), ShortChannelId(12L), ShortChannelId(13L))) val probe = TestProbe() val replyTo = probe.ref.toTyped[Router.PaymentRouteResponse] - val fr = Router.FinalizeRoute(replyTo, route, blip18 = Blip18Params(enableInboundFees = true)) + val fr = Router.FinalizeRoute(replyTo, route) - RouteCalculation.finalizeRoute(data, a, fr) + RouteCalculation.finalizeRoute(data, a, fr, Blip18Params(enableInboundFees = true)) probe.expectMsgType[Router.RouteResponse] match { case Router.RouteResponse(routes) => @@ -253,9 +253,9 @@ class Blip18FinalizeRouteSpec extends TestKitBaseClass with AnyFunSuiteLike with val route = PredefinedChannelRoute(100_000 msat, e, Seq(ShortChannelId(10L), ShortChannelId(11L), ShortChannelId(12L), ShortChannelId(13L))) val probe = TestProbe() val replyTo = probe.ref.toTyped[Router.PaymentRouteResponse] - val fr = Router.FinalizeRoute(replyTo, route, blip18 = Blip18Params.disabled) + val fr = Router.FinalizeRoute(replyTo, route) - RouteCalculation.finalizeRoute(data, a, fr) + RouteCalculation.finalizeRoute(data, a, fr, Blip18Params.disabled) probe.expectMsgType[Router.RouteResponse] match { case Router.RouteResponse(routes) => @@ -298,9 +298,9 @@ class Blip18FinalizeRouteSpec extends TestKitBaseClass with AnyFunSuiteLike with val route = PredefinedNodeRoute(100_000 msat, Seq(a, b, c)) val probe = TestProbe() val replyTo = probe.ref.toTyped[Router.PaymentRouteResponse] - val fr = Router.FinalizeRoute(replyTo, route, blip18 = Blip18Params(enableInboundFees = true, excludePositiveInboundFees = true)) + val fr = Router.FinalizeRoute(replyTo, route) - RouteCalculation.finalizeRoute(data, a, fr) + RouteCalculation.finalizeRoute(data, a, fr, Blip18Params(enableInboundFees = true, excludePositiveInboundFees = true)) probe.expectMsgType[Router.PaymentRouteNotFound] match { case Router.PaymentRouteNotFound(reason) =>