diff --git a/eclair-core/src/main/resources/reference.conf b/eclair-core/src/main/resources/reference.conf index 3b903dafd9..9845112ea7 100644 --- a/eclair-core/src/main/resources/reference.conf +++ b/eclair-core/src/main/resources/reference.conf @@ -429,6 +429,8 @@ eclair { } path-finding { + blip18-inbound-fees = false + exclude-channels-with-positive-inbound-fees = false default { randomize-route-selection = true // when computing a route for a payment we randomize the final selection 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 fc7a6fb913..da2d0c8e60 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala @@ -487,7 +487,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)).flatMap { + response <- appKit.router.toTyped.ask[PaymentRouteResponse](replyTo => RouteRequest(replyTo, sourceNodeId, target, routeParams1, ignore, blip18InboundFees = appKit.nodeParams.routerConf.blip18InboundFees, excludePositiveInboundFees = appKit.nodeParams.routerConf.excludePositiveInboundFees)).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 59d0dfca97..5e1f9589b0 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala @@ -481,7 +481,6 @@ object NodeParams extends Logging { experimentName = name, experimentPercentage = config.getInt("percentage")) - def getPathFindingExperimentConf(config: Config): PathFindingExperimentConf = { val experiments = config.root.asScala.keys.map(name => name -> getPathFindingConf(config.getConfig(name), name)) PathFindingExperimentConf(experiments.toMap) @@ -676,7 +675,9 @@ 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"), + ), socksProxy_opt = socksProxy_opt, maxPaymentAttempts = config.getInt("max-payment-attempts"), paymentFinalExpiry = PaymentFinalExpiryConf(CltvExpiryDelta(config.getInt("send.recipient-final-expiry.min-delta")), CltvExpiryDelta(config.getInt("send.recipient-final-expiry.max-delta"))), 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 98ef2b1e90..e8ccc89d50 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 @@ -86,7 +86,7 @@ object DefaultOfferHandler { val routeParams = nodeParams.routerConf.pathFindingExperimentConf.getRandomConf().getDefaultRouteParams .modify(_.boundaries.maxRouteLength).setTo(nodeParams.offersConfig.paymentPathLength) .modify(_.boundaries.maxCltv).setTo(nodeParams.offersConfig.paymentPathCltvExpiryDelta) - router ! BlindedRouteRequest(context.messageAdapter(WrappedRouteResponse), blindedPathFirstNodeId, nodeParams.nodeId, invoiceRequest.amount, routeParams, nodeParams.offersConfig.paymentPathCount) + router ! BlindedRouteRequest(context.messageAdapter(WrappedRouteResponse), blindedPathFirstNodeId, nodeParams.nodeId, invoiceRequest.amount, routeParams, nodeParams.offersConfig.paymentPathCount, blip18InboundFees = nodeParams.routerConf.blip18InboundFees, excludePositiveInboundFees = nodeParams.routerConf.excludePositiveInboundFees) waitForRoute(nodeParams, replyTo, invoiceRequest, blindedPathFirstNodeId, context) } } 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 dd706ecf6e..ba594d8f17 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 @@ -135,6 +135,14 @@ object Relayer extends Logging { val zero: RelayFees = RelayFees(MilliSatoshi(0), 0) } + 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/MultiPartPaymentLifecycle.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/MultiPartPaymentLifecycle.scala index 14e8669098..d776208646 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 @@ -370,7 +370,7 @@ object MultiPartPaymentLifecycle { case class PaymentSucceeded(request: SendMultiPartPayment, preimage: ByteVector32, parts: Seq[PartialPayment], pending: Set[UUID]) 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)) + 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) } 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 ab37eeaff0..eb5f3e08c3 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 @@ -54,7 +54,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)), + hops => router ! FinalizeRoute(self, hops, request.recipient.extraEdges, paymentContext = Some(cfg.paymentContext), nodeParams.routerConf.blip18InboundFees, nodeParams.routerConf.excludePositiveInboundFees), route => self ! RouteResponse(route :: Nil) ) if (cfg.storeInDb) { @@ -64,7 +64,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)) + router ! RouteRequest(self, nodeParams.nodeId, request.recipient, request.routeParams, paymentContext = Some(cfg.paymentContext), blip18InboundFees = nodeParams.routerConf.blip18InboundFees, excludePositiveInboundFees = nodeParams.routerConf.excludePositiveInboundFees) 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)) } @@ -135,7 +135,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)) + router ! RouteRequest(self, nodeParams.nodeId, data.recipient, request.routeParams, ignore1, paymentContext = Some(cfg.paymentContext), blip18InboundFees = nodeParams.routerConf.blip18InboundFees, excludePositiveInboundFees = nodeParams.routerConf.excludePositiveInboundFees) goto(WAITING_FOR_ROUTE) using WaitingForRoute(data.request, data.failures :+ failure, ignore1) case _: SendPaymentToRoute => log.error("unexpected retry during SendPaymentToRoute") @@ -241,7 +241,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)) + router ! RouteRequest(self, nodeParams.nodeId, recipient1, request.routeParams, ignore1, paymentContext = Some(cfg.paymentContext), blip18InboundFees = nodeParams.routerConf.blip18InboundFees, excludePositiveInboundFees = nodeParams.routerConf.excludePositiveInboundFees) goto(WAITING_FOR_ROUTE) using WaitingForRoute(request.copy(recipient = recipient1), failures :+ failure, ignore1) } } else { @@ -252,7 +252,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)) + router ! RouteRequest(self, nodeParams.nodeId, recipient, request.routeParams, ignore + nodeId, paymentContext = Some(cfg.paymentContext), blip18InboundFees = nodeParams.routerConf.blip18InboundFees, excludePositiveInboundFees = nodeParams.routerConf.excludePositiveInboundFees) goto(WAITING_FOR_ROUTE) using WaitingForRoute(request, failures :+ failure, ignore + nodeId) } } @@ -266,7 +266,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)) + router ! RouteRequest(self, nodeParams.nodeId, recipient, request.routeParams, ignore1, paymentContext = Some(cfg.paymentContext), blip18InboundFees = nodeParams.routerConf.blip18InboundFees, excludePositiveInboundFees = nodeParams.routerConf.excludePositiveInboundFees) 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 4538eed438..7fdc8c9a9d 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 @@ -114,7 +114,9 @@ object EclairInternalsSerializer { ("syncConf" | syncConfCodec) :: ("pathFindingExperimentConf" | pathFindingExperimentConfCodec) :: ("messageRouteParams" | messageRouteParamsCodec) :: - ("balanceEstimateHalfLife" | finiteDurationCodec)).as[RouterConf] + ("balanceEstimateHalfLife" | finiteDurationCodec) :: + ("blip18InboundFees" | bool(8)) :: + ("excludePositiveInboundFees" | bool(8))).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 832fc0d11a..a76c7955c1 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 @@ -22,6 +22,7 @@ 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.{DirectedGraph, GraphEdge} +import fr.acinq.eclair.router.Router.HopRelayParams import fr.acinq.eclair.router.Router._ import fr.acinq.eclair.wire.protocol.{ChannelUpdate, NodeAnnouncement} @@ -256,10 +257,11 @@ object Graph { wr: WeightRatios[PaymentPathWeight], currentBlockHeight: BlockHeight, boundaries: PaymentPathWeight => Boolean, - includeLocalChannelCost: Boolean): Seq[WeightedPath[PaymentPathWeight]] = { + includeLocalChannelCost: Boolean, + excludePositiveInboundFees: Boolean = false): Seq[WeightedPath[PaymentPathWeight]] = { // find the shortest path (k = 0) val targetWeight = PaymentPathWeight(amount) - dijkstraShortestPath(graph, sourceNode, targetNode, ignoredEdges, ignoredVertices, extraEdges, targetWeight, boundaries, Features.empty, currentBlockHeight, wr, includeLocalChannelCost) match { + dijkstraShortestPath(graph, sourceNode, targetNode, ignoredEdges, ignoredVertices, extraEdges, targetWeight, boundaries, Features.empty, currentBlockHeight, wr, includeLocalChannelCost, excludePositiveInboundFees) match { case None => Seq.empty // if we can't even find a single path, avoid returning a Seq(Seq.empty) case Some(shortestPath) => @@ -297,7 +299,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 - dijkstraShortestPath(graph, sourceNode, spurNode, ignoredEdges ++ alreadyExploredEdges, ignoredVertices ++ alreadyExploredVertices, extraEdges, rootPathWeight, boundaries, Features.empty, currentBlockHeight, wr, includeLocalChannelCost) match { + dijkstraShortestPath(graph, sourceNode, spurNode, ignoredEdges ++ alreadyExploredEdges, ignoredVertices ++ alreadyExploredVertices, extraEdges, rootPathWeight, boundaries, Features.empty, currentBlockHeight, wr, includeLocalChannelCost, excludePositiveInboundFees) match { case Some(spurPath) => val completePath = spurPath ++ rootPathEdges val candidatePath = WeightedPath(completePath, pathWeight(sourceNode, completePath, amount, currentBlockHeight, wr, includeLocalChannelCost)) @@ -349,7 +351,8 @@ object Graph { nodeFeatures: Features[NodeFeature], currentBlockHeight: BlockHeight, wr: WeightRatios[RichWeight], - includeLocalChannelCost: Boolean): Option[Seq[GraphEdge]] = { + includeLocalChannelCost: Boolean, + excludePositiveInboundFees: Boolean): Option[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) @@ -388,6 +391,7 @@ object Graph { if (current.weight.canUseEdge(edge) && !ignoredEdges.contains(edge.desc) && !ignoredVertices.contains(neighbor) && + (!excludePositiveInboundFees || g.getBackEdge(edge).flatMap(_.getChannelUpdate).flatMap(_.blip18InboundFees_opt).forall(i => i.feeBase.toLong <= 0 && i.feeProportionalMillionths <= 0)) && (neighbor == sourceNode || g.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`. @@ -432,7 +436,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) + 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) /** * Find non-overlapping (no vertices shared) payment paths that support route blinding @@ -449,12 +453,13 @@ object Graph { pathsToFind: Int, wr: WeightRatios[PaymentPathWeight], currentBlockHeight: BlockHeight, - boundaries: PaymentPathWeight => Boolean): Seq[WeightedPath[PaymentPathWeight]] = { + boundaries: PaymentPathWeight => Boolean, + excludePositiveInboundFees: Boolean): 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(graph, sourceNode, targetNode, ignoredEdges, verticesToIgnore.toSet, extraEdges = Set.empty, PaymentPathWeight(amount), boundaries, Features(Features.RouteBlinding -> FeatureSupport.Mandatory), currentBlockHeight, wr, includeLocalChannelCost = true) match { + dijkstraShortestPath(graph, sourceNode, targetNode, ignoredEdges, verticesToIgnore.toSet, extraEdges = Set.empty, PaymentPathWeight(amount), boundaries, Features(Features.RouteBlinding -> FeatureSupport.Mandatory), currentBlockHeight, wr, includeLocalChannelCost = true, excludePositiveInboundFees) match { case Some(path) => val weight = pathWeight(sourceNode, path, amount, currentBlockHeight, wr, includeLocalChannelCost = true) paths += WeightedPath(path, weight) @@ -553,6 +558,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 { @@ -668,6 +678,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 db9afcdcf2..b8d6e7702e 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 @@ -58,6 +58,14 @@ object RouteCalculation { } } + def validatePositiveInboundFees(route: Route, excludePositiveInboundFees: Boolean): Try[Route] = { + if (!excludePositiveInboundFees || route.hops.forall(hop => hop.params.inboundFees_opt.forall(i => i.feeBase <= 0.msat && i.feeProportionalMillionths <= 0))) { + Success(route) + } else { + Failure(new IllegalArgumentException("Route contains hops with positive inbound fees")) + } + } + Logs.withMdc(log)(Logs.mdc( category_opt = Some(LogCategory.PAYMENT), parentPaymentId_opt = fr.paymentContext.map(_.parentId), @@ -76,17 +84,28 @@ object RouteCalculation { // 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) => fr.replyTo ! RouteResponse(route :: Nil) + val route = if (fr.blip18InboundFees) { + validatePositiveInboundFees(routeWithInboundFees(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) } 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) => + case pcr@PredefinedChannelRoute(amount, targetNodeId, shortChannelIds, maxFee_opt) => + log.info(s"$pcr") 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)) @@ -100,16 +119,27 @@ 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 { - validateMaxRouteFee(Route(amount, hops, None), maxFee_opt) match { - case Success(route) => fr.replyTo ! RouteResponse(route :: Nil) + val route = if (fr.blip18InboundFees) { + validatePositiveInboundFees(routeWithInboundFees(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) } } @@ -199,9 +229,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.graph, r.source, targetNodeId, amountToSend, maxFee, extraEdges, ignoredEdges, r.ignore.nodes, r.pendingPayments, r.routeParams, currentBlockHeight) + findMultiPartRoute(d.graphWithBalances.graph, r.source, targetNodeId, amountToSend, maxFee, extraEdges, ignoredEdges, r.ignore.nodes, r.pendingPayments, r.routeParams, currentBlockHeight, r.blip18InboundFees, r.excludePositiveInboundFees) } else { - findRoute(d.graphWithBalances.graph, r.source, targetNodeId, amountToSend, maxFee, routesToFind, extraEdges, ignoredEdges, r.ignore.nodes, r.routeParams, currentBlockHeight) + findRoute(d.graphWithBalances.graph, r.source, targetNodeId, amountToSend, maxFee, routesToFind, extraEdges, ignoredEdges, r.ignore.nodes, r.routeParams, currentBlockHeight, r.blip18InboundFees, r.excludePositiveInboundFees) } result.map(routes => addFinalHop(r.target, routes)) match { case Success(routes) => @@ -239,7 +269,7 @@ object RouteCalculation { weight.cltv <= r.routeParams.boundaries.maxCltv } - val routes = Graph.routeBlindingPaths(d.graphWithBalances.graph, r.source, r.target, r.amount, r.ignore.channels, r.ignore.nodes, r.pathsToFind, r.routeParams.heuristics, currentBlockHeight, boundaries) + val routes = Graph.routeBlindingPaths(d.graphWithBalances.graph, r.source, r.target, r.amount, r.ignore.channels, r.ignore.nodes, r.pathsToFind, r.routeParams.heuristics, currentBlockHeight, boundaries, r.excludePositiveInboundFees) if (routes.isEmpty) { r.replyTo ! PaymentRouteNotFound(RouteNotFound) } else { @@ -313,13 +343,44 @@ object RouteCalculation { ignoredEdges: Set[ChannelDesc] = Set.empty, ignoredVertices: Set[PublicKey] = Set.empty, 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)) + currentBlockHeight: BlockHeight, + blip18InboundFees: Boolean = false, + excludePositiveInboundFees: Boolean = false, + ): Try[Seq[Route]] = Try { + findRouteInternal(g, localNodeId, targetNodeId, amount, maxFee, numRoutes, extraEdges, ignoredEdges, ignoredVertices, routeParams, currentBlockHeight, excludePositiveInboundFees) match { + case Right(routes) => routes.map { route => + if (blip18InboundFees) + routeWithInboundFees(amount, route.path.map(graphEdgeToHop), g) + else + Route(amount, route.path.map(graphEdgeToHop), None) + } case Left(ex) => return Failure(ex) } } + private def routeWithInboundFees(amount: MilliSatoshi, routeHops: Seq[ChannelHop], g: DirectedGraph): Route = { + if (routeHops.tail.isEmpty) { + Route(amount, routeHops, None) + } else { + val hops = routeHops.reverse + val updatedHops = routeHops.head :: hops.zip(hops.tail).foldLeft(List.empty[ChannelHop]) { (hops, x) => + val (curr, prev) = x + val backEdge_opt = g.getBackEdge(ChannelDesc(prev.shortChannelId, prev.nodeId, prev.nextNodeId)) + val hop = curr.copy(params = curr.params match { + case hopParams: HopRelayParams.FromAnnouncement => + backEdge_opt + .flatMap(_.getChannelUpdate) + .map(u => hopParams.copy(inboundFees_opt = u.blip18InboundFees_opt)) + .getOrElse(hopParams) + case hopParams => hopParams + }) + + hop :: hops + } + Route(amount, updatedHops, None) + } + } + @tailrec private def findRouteInternal(g: DirectedGraph, localNodeId: PublicKey, @@ -331,7 +392,9 @@ object RouteCalculation { ignoredEdges: Set[ChannelDesc] = Set.empty, ignoredVertices: Set[PublicKey] = Set.empty, routeParams: RouteParams, - currentBlockHeight: BlockHeight): Either[RouterException, Seq[WeightedPath[PaymentPathWeight]]] = { + currentBlockHeight: BlockHeight, + excludePositiveInboundFees: Boolean): Either[RouterException, Seq[WeightedPath[PaymentPathWeight]]] = { + require(amount > 0.msat, "route amount must be strictly positive") if (localNodeId == targetNodeId) return Left(CannotRouteToSelf) @@ -344,7 +407,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) + val foundRoutes: Seq[WeightedPath[PaymentPathWeight]] = Graph.yenKshortestPaths(g, localNodeId, targetNodeId, amount, ignoredEdges, ignoredVertices, extraEdges, numRoutes, routeParams.heuristics, currentBlockHeight, boundaries, routeParams.includeLocalChannelCost, excludePositiveInboundFees) if (foundRoutes.nonEmpty) { val (directRoutes, indirectRoutes) = foundRoutes.partition(_.path.length == 1) val routes = if (routeParams.randomize) { @@ -358,7 +421,7 @@ object RouteCalculation { val relaxedRouteParams = routeParams .modify(_.boundaries.maxRouteLength).setTo(ROUTE_MAX_LENGTH) .modify(_.boundaries.maxCltv).setTo(DEFAULT_ROUTE_MAX_CLTV) - findRouteInternal(g, localNodeId, targetNodeId, amount, maxFee, numRoutes, extraEdges, ignoredEdges, ignoredVertices, relaxedRouteParams, currentBlockHeight) + findRouteInternal(g, localNodeId, targetNodeId, amount, maxFee, numRoutes, extraEdges, ignoredEdges, ignoredVertices, relaxedRouteParams, currentBlockHeight, excludePositiveInboundFees) } else { Left(RouteNotFound) } @@ -389,12 +452,14 @@ object RouteCalculation { ignoredVertices: Set[PublicKey] = Set.empty, pendingHtlcs: Seq[Route] = Nil, routeParams: RouteParams, - currentBlockHeight: BlockHeight): Try[Seq[Route]] = Try { - val result = findMultiPartRouteInternal(g, localNodeId, targetNodeId, amount, maxFee, extraEdges, ignoredEdges, ignoredVertices, pendingHtlcs, routeParams, currentBlockHeight) match { + currentBlockHeight: BlockHeight, + blip18InboundFees: Boolean = false, + excludePositiveInboundFees: Boolean = false): Try[Seq[Route]] = Try { + val result = findMultiPartRouteInternal(g, localNodeId, targetNodeId, amount, maxFee, extraEdges, ignoredEdges, ignoredVertices, pendingHtlcs, routeParams, currentBlockHeight, blip18InboundFees, excludePositiveInboundFees) match { case Right(routes) => Right(routes) case Left(RouteNotFound) if routeParams.randomize => // If we couldn't find a randomized solution, fallback to a deterministic one. - findMultiPartRouteInternal(g, localNodeId, targetNodeId, amount, maxFee, extraEdges, ignoredEdges, ignoredVertices, pendingHtlcs, routeParams.copy(randomize = false), currentBlockHeight) + findMultiPartRouteInternal(g, localNodeId, targetNodeId, amount, maxFee, extraEdges, ignoredEdges, ignoredVertices, pendingHtlcs, routeParams.copy(randomize = false), currentBlockHeight, blip18InboundFees, excludePositiveInboundFees) case Left(ex) => Left(ex) } result match { @@ -413,7 +478,9 @@ object RouteCalculation { ignoredVertices: Set[PublicKey] = Set.empty, pendingHtlcs: Seq[Route] = Nil, routeParams: RouteParams, - currentBlockHeight: BlockHeight): Either[RouterException, Seq[Route]] = { + currentBlockHeight: BlockHeight, + blip18InboundFees: Boolean, + excludePositiveInboundFees: Boolean): Either[RouterException, Seq[Route]] = { // We use Yen's k-shortest paths to find many paths for chunks of the total amount. // When the recipient is a direct peer, we have complete visibility on our local channels so we can use more accurate MPP parameters. val routeParams1 = { @@ -431,11 +498,15 @@ object RouteCalculation { val minPartAmount = routeParams.mpp.minPartAmount.max(amount / numRoutes).min(amount) routeParams.copy(mpp = MultiPartParams(minPartAmount, numRoutes)) } - findRouteInternal(g, localNodeId, targetNodeId, routeParams1.mpp.minPartAmount, maxFee, routeParams1.mpp.maxParts, extraEdges, ignoredEdges, ignoredVertices, routeParams1, currentBlockHeight) match { + findRouteInternal(g, localNodeId, targetNodeId, routeParams1.mpp.minPartAmount, maxFee, routeParams1.mpp.maxParts, extraEdges, ignoredEdges, ignoredVertices, routeParams1, currentBlockHeight, excludePositiveInboundFees) match { 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 (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 ce67a7c96a..2751613222 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 @@ -367,7 +367,7 @@ object Router { heuristics = heuristics, mpp = mpp, experimentName = experimentName, - includeLocalChannelCost = false + includeLocalChannelCost = false, ) } @@ -387,7 +387,9 @@ object Router { syncConf: SyncConf, pathFindingExperimentConf: PathFindingExperimentConf, messageRouteParams: MessageRouteParams, - balanceEstimateHalfLife: FiniteDuration) + balanceEstimateHalfLife: FiniteDuration, + blip18InboundFees: Boolean, + excludePositiveInboundFees: Boolean) // @formatter:off case class ChannelDesc private(shortChannelId: ShortChannelId, a: PublicKey, b: PublicKey){ @@ -492,7 +494,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 @@ -500,7 +508,7 @@ 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, inboundFees_opt: Option[Relayer.InboundFees] = None) extends HopRelayParams { override val cltvExpiryDelta = channelUpdate.cltvExpiryDelta override val relayFees = channelUpdate.relayFees override val htlcMinimum = channelUpdate.htlcMinimumMsat @@ -511,6 +519,7 @@ object Router { 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 } @@ -518,6 +527,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)) } @@ -615,7 +625,9 @@ object Router { ignore: Ignore = Ignore.empty, allowMultiPart: Boolean = false, pendingPayments: Seq[Route] = Nil, - paymentContext: Option[PaymentContext] = None) + paymentContext: Option[PaymentContext] = None, + blip18InboundFees: Boolean = false, + excludePositiveInboundFees: Boolean = false) case class BlindedRouteRequest(replyTo: typed.ActorRef[PaymentRouteResponse], source: PublicKey, @@ -623,12 +635,16 @@ object Router { amount: MilliSatoshi, routeParams: RouteParams, pathsToFind: Int, - ignore: Ignore = Ignore.empty) + ignore: Ignore = Ignore.empty, + blip18InboundFees: Boolean, + excludePositiveInboundFees: Boolean) case class FinalizeRoute(replyTo: typed.ActorRef[PaymentRouteResponse], route: PredefinedRoute, extraEdges: Seq[ExtraEdge] = Nil, - paymentContext: Option[PaymentContext] = None) + paymentContext: Option[PaymentContext] = None, + blip18InboundFees: Boolean = false, + excludePositiveInboundFees: Boolean = false) sealed trait PostmanRequest @@ -702,7 +718,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/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala index 11034b42d0..09d59efd9f 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 @@ -563,6 +563,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 blip18InboundFees_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/TestConstants.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala index 4906ab3e06..51629fa1e0 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala @@ -203,7 +203,7 @@ object TestConstants { channelRangeChunkSize = 20, channelQueryChunkSize = 5, peerLimit = 10, - whitelist = Set.empty + whitelist = Set.empty, ), pathFindingExperimentConf = PathFindingExperimentConf(Map("alice-test-experiment" -> PathFindingConf( randomize = false, @@ -227,6 +227,8 @@ object TestConstants { experimentPercentage = 100))), messageRouteParams = MessageRouteParams(8, MessageWeightRatios(0.7, 0.1, 0.2)), balanceEstimateHalfLife = 1 day, + blip18InboundFees = false, + excludePositiveInboundFees = false, ), socksProxy_opt = None, maxPaymentAttempts = 5, @@ -410,6 +412,8 @@ object TestConstants { experimentPercentage = 100))), messageRouteParams = MessageRouteParams(9, MessageWeightRatios(0.5, 0.2, 0.3)), balanceEstimateHalfLife = 1 day, + blip18InboundFees = false, + excludePositiveInboundFees = false, ), socksProxy_opt = None, maxPaymentAttempts = 5, 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..e426d59106 --- /dev/null +++ b/eclair-core/src/test/scala/fr/acinq/eclair/router/Blip18RouteCalculationSpec.scala @@ -0,0 +1,379 @@ +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.PaymentWeightRatios +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("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), blip18InboundFees = true, excludePositiveInboundFees = true) + + assert(route.channelFee(true) == 1200.msat) + } + + 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), 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, 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, 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, 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), 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, 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, 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, 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 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 = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true, excludePositiveInboundFees = true) + 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 = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true, excludePositiveInboundFees = true) + 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 = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true, excludePositiveInboundFees = true) + 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 = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true, excludePositiveInboundFees = true) + 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 = DEFAULT_ROUTE_PARAMS, currentBlockHeight = BlockHeight(400000), blip18InboundFees = true, excludePositiveInboundFees = true) + 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: PaymentWeightRatios = PaymentWeightRatios(1, 0, 0, 0, RelayFees(0 msat, 0)) + val DEFAULT_ROUTE_PARAMS = PathFindingConf( + randomize = false, + boundaries = SearchBoundaries(21000 msat, 0.03, 6, CltvExpiryDelta(2016)), + NO_WEIGHT_RATIOS, + MultiPartParams(1000 msat, 10), + 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, 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/GraphSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/router/GraphSpec.scala index c84799c77f..8072863c41 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 @@ -522,13 +522,13 @@ class GraphSpec extends AnyFunSuite { .addOrUpdateVertex(makeNodeAnnouncement(priv_h, "H", Color(0, 0, 0), Nil, Features(Features.RouteBlinding -> FeatureSupport.Optional))) { - val paths = routeBlindingPaths(graph, a, h, 20_000_000 msat, Set.empty, Set.empty, pathsToFind = 3, PaymentWeightRatios(1, 0, 0, 0, RelayFees(0 msat, 0)), BlockHeight(793397), _ => true) + val paths = routeBlindingPaths(graph, a, h, 20_000_000 msat, Set.empty, Set.empty, pathsToFind = 3, PaymentWeightRatios(1, 0, 0, 0, RelayFees(0 msat, 0)), BlockHeight(793397), _ => true, 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(graph, c, h, 20_000_000 msat, Set.empty, Set.empty, pathsToFind = 3, PaymentWeightRatios(1, 0, 0, 0, RelayFees(0 msat, 0)), BlockHeight(793397), _ => true) + val paths = routeBlindingPaths(graph, c, h, 20_000_000 msat, Set.empty, Set.empty, pathsToFind = 3, PaymentWeightRatios(1, 0, 0, 0, RelayFees(0 msat, 0)), BlockHeight(793397), _ => true, false) 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 d7ad81bf67..d373a1066a 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)