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 03f27b57e..88aacd172 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 @@ -9,9 +9,8 @@ import Router._ object Graph { - import DirectedGraph._ - case class WeightedNode(key: PublicKey, weight: Long) + case class WeightedPath(path: Seq[GraphEdge], weight: Long) /** * This comparator must be consistent with the "equals" behavior, thus for two weighted nodes with @@ -25,6 +24,99 @@ object Graph { } } + implicit object PathComparator extends Ordering[WeightedPath] { + override def compare(x: WeightedPath, y: WeightedPath): Int = y.weight.compareTo(x.weight) + } + /** + * Yen's algorithm to find the k-shortest (loopless) paths in a graph, uses dijkstra as search algo. Is guaranteed to terminate finding + * at most @pathsToFind paths sorted by cost (the cheapest is in position 0). + * @param graph + * @param sourceNode + * @param targetNode + * @param amountMsat + * @param pathsToFind + * @return + */ + def yenKshortestPaths(graph: DirectedGraph, sourceNode: PublicKey, targetNode: PublicKey, amountMsat: Long, ignoredEdges: Set[ChannelDesc], extraEdges: Set[GraphEdge], pathsToFind: Int): Seq[WeightedPath] = { + + var allSpurPathsFound = false + + // stores the shortest paths + val shortestPaths = new mutable.MutableList[WeightedPath] + // stores the candidates for k(K +1) shortest paths, sorted by path cost + val candidates = new mutable.PriorityQueue[WeightedPath] + + // find the shortest path, k = 0 + val shortestPath = dijkstraShortestPath(graph, sourceNode, targetNode, amountMsat, ignoredEdges, extraEdges) + shortestPaths += WeightedPath(shortestPath, pathCost(shortestPath, amountMsat)) + + // main loop + for(k <- 1 until pathsToFind) { + + if ( !allSpurPathsFound ) { + + // for every edge in the path + for (i <- shortestPaths(k - 1).path.indices) { + + val prevShortestPath = shortestPaths(k - 1).path + + // select the spur node as the i-th element of the k-th previous shortest path (k -1) + val spurEdge = prevShortestPath(i) + + // select the subpath from the source to the spur node of the k-th previous shortest path + val rootPathEdges = if(i == 0) prevShortestPath.head :: Nil else prevShortestPath.take(i) + + // links to be removed that are part of the previous shortest path and which share the same root path + val edgesToIgnore = shortestPaths.flatMap { weightedPath => + if ( (i == 0 && (weightedPath.path.head :: Nil) == rootPathEdges) || weightedPath.path.take(i) == rootPathEdges ) { + weightedPath.path(i).desc :: Nil + } else { + Nil + } + } + + // find the "spur" path, a subpath going from the spur edge to the target avoiding previously found subpaths + val spurPath = dijkstraShortestPath(graph, spurEdge.desc.a, targetNode, amountMsat, ignoredEdges ++ edgesToIgnore.toSet, extraEdges) + + // if there wasn't a path the spur will be empty + if(spurPath.nonEmpty) { + + // candidate k-shortest path is made of the rootPath and the new spurPath + val totalPath = rootPathEdges.head.desc.a == spurPath.head.desc.a match { + case true => rootPathEdges.tail ++ spurPath // if the heads are the same node, drop it from the rootPath + case false => rootPathEdges ++ spurPath + } + + //val totalPath = concat(rootPathEdges, spurPath.toList) + val candidatePath = WeightedPath(totalPath, pathCost(totalPath, amountMsat)) + + if (!shortestPaths.contains(candidatePath) && !candidates.exists(_ == candidatePath)) { + candidates.enqueue(candidatePath) + } + + } + } + } + + if(candidates.isEmpty) { + // handles the case of having exhausted all possible spur paths and it's impossible to reach the target from the source + allSpurPathsFound = true + } else { + // move the best candidate to the shortestPaths container + shortestPaths += candidates.dequeue() + } + } + + shortestPaths + } + + // Calculates the total cost of a path (amount + fees), direct channels with the source will have a cost of 0 (pay no fees) + def pathCost(path: Seq[GraphEdge], amountMsat: Long): Long = { + path.drop(1).foldRight(amountMsat) { (edge, cost) => + edgeWeight(edge, cost, isNeighborTarget = false) + } + } + /** * Finds the shortest path in the graph, uses a modified version of Dijsktra's algorithm that computes * the shortest path from the target to the source (this is because we want to calculate the weight of the @@ -38,9 +130,6 @@ object Graph { * @param extraEdges a list of extra edges we want to consider but are not currently in the graph * @return */ - def shortestPath(g: DirectedGraph, sourceNode: PublicKey, targetNode: PublicKey, amountMsat: Long, ignoredEdges: Set[ChannelDesc], extraEdges: Set[GraphEdge]): Seq[Hop] = { - dijkstraShortestPath(g, sourceNode, targetNode, amountMsat, ignoredEdges, extraEdges).map(graphEdgeToHop) - } def dijkstraShortestPath(g: DirectedGraph, sourceNode: PublicKey, targetNode: PublicKey, amountMsat: Long, ignoredEdges: Set[ChannelDesc], extraEdges: Set[GraphEdge]): Seq[GraphEdge] = { 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 528b08c79..ec2ae6113 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 @@ -28,7 +28,9 @@ import fr.acinq.eclair.channel._ import fr.acinq.eclair.crypto.TransportHandler import fr.acinq.eclair.io.Peer.{ChannelClosed, InvalidSignature, NonexistingChannel, PeerRoutingMessage} import fr.acinq.eclair.payment.PaymentRequest.ExtraHop +import fr.acinq.eclair.router.Graph.GraphStructure.DirectedGraph.graphEdgeToHop import fr.acinq.eclair.router.Graph.GraphStructure.{DirectedGraph, GraphEdge} +import fr.acinq.eclair.router.Graph.WeightedPath import fr.acinq.eclair.transactions.Scripts import fr.acinq.eclair.wire._ import scala.collection.{SortedSet, mutable} @@ -36,7 +38,7 @@ import scala.collection.immutable.{SortedMap, TreeMap} import scala.compat.Platform import scala.concurrent.duration._ import scala.concurrent.{ExecutionContext, Promise} -import scala.util.Try +import scala.util.{Random, Try} // @formatter:off @@ -377,7 +379,8 @@ class Router(nodeParams: NodeParams, watcher: ActorRef, initialized: Option[Prom val ignoredUpdates = getIgnoredChannelDesc(d.updates ++ d.privateUpdates ++ assistedUpdates, ignoreNodes) ++ ignoreChannels ++ d.excludedChannels log.info(s"finding a route $start->$end with assistedChannels={} ignoreNodes={} ignoreChannels={} excludedChannels={}", assistedUpdates.keys.mkString(","), ignoreNodes.map(_.toBin).mkString(","), ignoreChannels.mkString(","), d.excludedChannels.mkString(",")) val extraEdges = assistedUpdates.map { case (c, u) => GraphEdge(c, u) }.toSet - findRoute(d.graph, start, end, amount, extraEdges = extraEdges, ignoredEdges = ignoredUpdates.toSet) + // we ask the router to make a random selection among the three best routes, numRoutes = 3 + findRoute(d.graph, start, end, amount, numRoutes = DEFAULT_ROUTES_COUNT, extraEdges = extraEdges, ignoredEdges = ignoredUpdates.toSet) .map(r => sender ! RouteResponse(r, ignoreNodes, ignoreChannels)) .recover { case t => sender ! Status.Failure(t) } stay @@ -776,24 +779,42 @@ object Router { */ val ROUTE_MAX_LENGTH = 20 + // The default amount of routes we'll search for when findRoute is called + val DEFAULT_ROUTES_COUNT = 3 + + // The default allowed 'spread' between the cheapest route found an the others + // routes exceeding this difference won't be considered as a valid result + val DEFAULT_ALLOWED_SPREAD = 0.1D /** - * Find a route in the graph between localNodeId and targetNodeId, returns the route and its cost + * Find a route in the graph between localNodeId and targetNodeId, returns the route. + * Will perform a k-shortest path selection given the @param numRoutes and randomly select one of the result, + * the 'route-set' from where we select the result is made of the k-shortest path given that none of them + * exceeds a 10% spread with the cheapest route * * @param g * @param localNodeId * @param targetNodeId * @param amountMsat the amount that will be sent along this route + * @param numRoutes the number of shortest-paths to find * @param extraEdges a set of extra edges we want to CONSIDER during the search * @param ignoredEdges a set of extra edges we want to IGNORE during the search * @return the computed route to the destination @targetNodeId */ - def findRoute(g: DirectedGraph, localNodeId: PublicKey, targetNodeId: PublicKey, amountMsat: Long, extraEdges: Set[GraphEdge] = Set.empty, ignoredEdges: Set[ChannelDesc] = Set.empty): Try[Seq[Hop]] = Try { + def findRoute(g: DirectedGraph, localNodeId: PublicKey, targetNodeId: PublicKey, amountMsat: Long, numRoutes: Int, extraEdges: Set[GraphEdge] = Set.empty, ignoredEdges: Set[ChannelDesc] = Set.empty): Try[Seq[Hop]] = Try { if (localNodeId == targetNodeId) throw CannotRouteToSelf - Graph.shortestPath(g, localNodeId, targetNodeId, amountMsat, ignoredEdges, extraEdges) match { + val foundRoutes = Graph.yenKshortestPaths(g, localNodeId, targetNodeId, amountMsat, ignoredEdges, extraEdges, numRoutes).toList match { case Nil => throw RouteNotFound - case path => path + case route :: Nil if route.path.isEmpty => throw RouteNotFound + case foundRoutes => foundRoutes } + + // minimum cost + val minimumCost = foundRoutes.head.weight + + // routes paying at most minimumCost + 10% + val eligibleRoutes = foundRoutes.filter(_.weight <= (minimumCost + minimumCost * DEFAULT_ALLOWED_SPREAD).round) + Random.shuffle(eligibleRoutes).head.path.map(graphEdgeToHop) } } 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 b6b28559d..96dc78c37 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 @@ -19,11 +19,11 @@ package fr.acinq.eclair.router import fr.acinq.bitcoin.Crypto.{PrivateKey, PublicKey} import fr.acinq.bitcoin.{BinaryData, Block, Crypto} import fr.acinq.eclair.payment.PaymentRequest.ExtraHop +import fr.acinq.eclair.router.Graph.GraphStructure.DirectedGraph.graphEdgeToHop import fr.acinq.eclair.router.Graph.GraphStructure.{DirectedGraph, GraphEdge} import fr.acinq.eclair.wire._ -import fr.acinq.eclair.{ShortChannelId, nodeFee, randomKey} +import fr.acinq.eclair.{ShortChannelId, randomKey} import org.scalatest.FunSuite - import scala.util.{Failure, Success} /** @@ -36,14 +36,6 @@ class RouteCalculationSpec extends FunSuite { val (a, b, c, d, e) = (randomKey.publicKey, randomKey.publicKey, randomKey.publicKey, randomKey.publicKey, randomKey.publicKey) - - // the total fee cost for this path - def pathCost(path: Seq[Hop], amountMsat: Long): Long = { - path.drop(1).reverse.foldLeft(amountMsat) { (fee, hop) => - fee + nodeFee(hop.lastUpdate.feeBaseMsat, hop.lastUpdate.feeProportionalMillionths, fee) - } - } - test("calculate simple route") { val updates = List( @@ -55,7 +47,7 @@ class RouteCalculationSpec extends FunSuite { val g = makeGraph(updates) - val route = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT) + val route = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, numRoutes = 1) assert(route.map(hops2Ids) === Success(1 :: 2 :: 3 :: 4 :: Nil)) } @@ -93,16 +85,16 @@ class RouteCalculationSpec extends FunSuite { val graph = makeGraph(updates) - val Success(route) = Router.findRoute(graph, a, d, amountMsat) + val Success(route) = Router.findRoute(graph, a, d, amountMsat, numRoutes = 1) assert(hops2Ids(route) === 4 :: 5 :: 6 :: Nil) - assert(pathCost(route, amountMsat) === expectedCost) + assert(Graph.pathCost(hops2Edges(route), amountMsat) === expectedCost) // now channel 5 could route the amount (10000) but not the amount + fees (10007) val (desc, update) = makeUpdate(5L, e, f, feeBaseMsat = 1, feeProportionalMillionth = 400, minHtlcMsat = 0, maxHtlcMsat = Some(10005)) val graph1 = graph.addEdge(desc, update) - val Success(route1) = Router.findRoute(graph1, a, d, amountMsat) + val Success(route1) = Router.findRoute(graph1, a, d, amountMsat, numRoutes = 1) assert(hops2Ids(route1) === 1 :: 2 :: 3 :: Nil) } @@ -117,7 +109,7 @@ class RouteCalculationSpec extends FunSuite { ).toMap val g = makeGraph(updates) - val route = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT) + val route = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, numRoutes = 1) assert(route.map(hops2Ids) === Success(2 :: 5 :: Nil)) } @@ -133,11 +125,11 @@ class RouteCalculationSpec extends FunSuite { val g = makeGraph(updates) - val route1 = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT) + val route1 = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, numRoutes = 1) assert(route1.map(hops2Ids) === Success(1 :: 2 :: 3 :: 4 :: Nil)) val graphWithRemovedEdge = g.removeEdge(ChannelDesc(ShortChannelId(3L), c, d)) - val route2 = Router.findRoute(graphWithRemovedEdge, a, e, DEFAULT_AMOUNT_MSAT) + val route2 = Router.findRoute(graphWithRemovedEdge, a, e, DEFAULT_AMOUNT_MSAT, numRoutes = 1) assert(route2.map(hops2Ids) === Failure(RouteNotFound)) } @@ -159,7 +151,7 @@ class RouteCalculationSpec extends FunSuite { val graph = makeGraph(updates) - val route = Router.findRoute(graph, f, i, DEFAULT_AMOUNT_MSAT) + val route = Router.findRoute(graph, f, i, DEFAULT_AMOUNT_MSAT, numRoutes = 1) assert(route.map(hops2Ids) === Success(4 :: 3 :: Nil)) } @@ -182,7 +174,7 @@ class RouteCalculationSpec extends FunSuite { val graph = makeGraph(updates) - val route = Router.findRoute(graph, f, i, DEFAULT_AMOUNT_MSAT) + val route = Router.findRoute(graph, f, i, DEFAULT_AMOUNT_MSAT, numRoutes = 1) assert(route.map(hops2Ids) === Success(4 :: 3 :: Nil)) } @@ -204,7 +196,7 @@ class RouteCalculationSpec extends FunSuite { val graph = makeGraph(updates) - val route = Router.findRoute(graph, f, i, DEFAULT_AMOUNT_MSAT) + val route = Router.findRoute(graph, f, i, DEFAULT_AMOUNT_MSAT, numRoutes = 1) assert(route.map(hops2Ids) === Success(1 :: 6 :: 3 :: Nil)) } @@ -220,7 +212,7 @@ class RouteCalculationSpec extends FunSuite { val g = makeGraph(updates) - val route = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT) + val route = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, numRoutes = 1) assert(route.map(hops2Ids) === Success(1 :: 2 :: 3 :: 4 :: Nil)) } @@ -233,7 +225,7 @@ class RouteCalculationSpec extends FunSuite { val g = makeGraph(updates) - val route = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT) + val route = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, numRoutes = 1) assert(route.map(hops2Ids) === Failure(RouteNotFound)) } @@ -247,7 +239,7 @@ class RouteCalculationSpec extends FunSuite { val g = makeGraph(updates) - val route = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT) + val route = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, numRoutes = 1) assert(route.map(hops2Ids) === Failure(RouteNotFound)) } @@ -260,7 +252,7 @@ class RouteCalculationSpec extends FunSuite { val g = makeGraph(updates).addVertex(a) - val route = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT) + val route = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, numRoutes = 1) assert(route.map(hops2Ids) === Failure(RouteNotFound)) } @@ -274,7 +266,7 @@ class RouteCalculationSpec extends FunSuite { val g = makeGraph(updates) - val route = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT) + val route = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, numRoutes = 1) assert(route.map(hops2Ids) === Failure(RouteNotFound)) } @@ -288,7 +280,7 @@ class RouteCalculationSpec extends FunSuite { val g = makeGraph(updates) - val route = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT) + val route = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, numRoutes = 1) assert(route.map(hops2Ids) === Failure(RouteNotFound)) } @@ -304,7 +296,7 @@ class RouteCalculationSpec extends FunSuite { val g = makeGraph(updates) - val route = Router.findRoute(g, a, d, highAmount) + val route = Router.findRoute(g, a, d, highAmount, numRoutes = 1) assert(route.map(hops2Ids) === Failure(RouteNotFound)) } @@ -321,7 +313,7 @@ class RouteCalculationSpec extends FunSuite { val g = makeGraph(updates) - val route = Router.findRoute(g, a, d, lowAmount) + val route = Router.findRoute(g, a, d, lowAmount, numRoutes = 1) assert(route.map(hops2Ids) === Failure(RouteNotFound)) } @@ -336,7 +328,7 @@ class RouteCalculationSpec extends FunSuite { val g = makeGraph(updates) - val route = Router.findRoute(g, a, a, DEFAULT_AMOUNT_MSAT) + val route = Router.findRoute(g, a, a, DEFAULT_AMOUNT_MSAT, numRoutes = 1) assert(route.map(hops2Ids) === Failure(CannotRouteToSelf)) } @@ -351,7 +343,7 @@ class RouteCalculationSpec extends FunSuite { val g = makeGraph(updates) - val route = Router.findRoute(g, a, b, DEFAULT_AMOUNT_MSAT) + val route = Router.findRoute(g, a, b, DEFAULT_AMOUNT_MSAT, numRoutes = 1) assert(route.map(hops2Ids) === Success(1 :: Nil)) } @@ -367,10 +359,10 @@ class RouteCalculationSpec extends FunSuite { val g = makeGraph(updates) - val route1 = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT) + val route1 = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, numRoutes = 1) assert(route1.map(hops2Ids) === Success(1 :: 2 :: 3 :: 4 :: Nil)) - val route2 = Router.findRoute(g, e, a, DEFAULT_AMOUNT_MSAT) + val route2 = Router.findRoute(g, e, a, DEFAULT_AMOUNT_MSAT, numRoutes = 1) assert(route2.map(hops2Ids) === Failure(RouteNotFound)) } @@ -407,7 +399,7 @@ class RouteCalculationSpec extends FunSuite { val g = makeGraph(updates) - val hops = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT).get + val hops = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, numRoutes = 1).get assert(hops === Hop(a, b, uab) :: Hop(b, c, ubc) :: Hop(c, d, ucd) :: Hop(d, e, ude) :: Nil) } @@ -447,7 +439,7 @@ class RouteCalculationSpec extends FunSuite { val g = makeGraph(updates) - val route1 = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, ignoredEdges = Set(ChannelDesc(ShortChannelId(3L), c, d))) + val route1 = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, numRoutes = 1 , ignoredEdges = Set(ChannelDesc(ShortChannelId(3L), c, d))) assert(route1.map(hops2Ids) === Failure(RouteNotFound)) // verify that we left the graph untouched @@ -456,7 +448,7 @@ class RouteCalculationSpec extends FunSuite { assert(g.containsVertex(d)) // make sure we can find a route if without the blacklist - val route2 = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT) + val route2 = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, numRoutes = 1) assert(route2.map(hops2Ids) === Success(1 :: 2 :: 3 :: 4 :: Nil)) } @@ -469,14 +461,14 @@ class RouteCalculationSpec extends FunSuite { val g = makeGraph(updates) - val route = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT) + val route = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, numRoutes = 1) assert(route.map(hops2Ids) === Failure(RouteNotFound)) // now we add the missing edge to reach the destination val (extraDesc, extraUpdate) = makeUpdate(4L, d, e, 5, 5) val extraGraphEdges = Set(GraphEdge(extraDesc, extraUpdate)) - val route1 = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, extraEdges = extraGraphEdges) + val route1 = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, numRoutes = 1, extraEdges = extraGraphEdges) assert(route1.map(hops2Ids) === Success(1 :: 2 :: 3 :: 4 :: Nil)) } @@ -491,7 +483,7 @@ class RouteCalculationSpec extends FunSuite { val g = makeGraph(updates) - val route1 = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT) + val route1 = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, numRoutes = 1) assert(route1.map(hops2Ids) === Success(1 :: 2 :: 3 :: 4 :: Nil)) assert(route1.get(1).lastUpdate.feeBaseMsat == 10) @@ -499,7 +491,7 @@ class RouteCalculationSpec extends FunSuite { val extraGraphEdges = Set(GraphEdge(extraDesc, extraUpdate)) - val route2 = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, extraEdges = extraGraphEdges) + val route2 = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, numRoutes = 1, extraEdges = extraGraphEdges) assert(route2.map(hops2Ids) === Success(1 :: 2 :: 3 :: 4 :: Nil)) assert(route2.get(1).lastUpdate.feeBaseMsat == 5) } @@ -557,10 +549,10 @@ class RouteCalculationSpec extends FunSuite { val g = makeGraph(updates) - assert(Router.findRoute(g, nodes(0), nodes(18), DEFAULT_AMOUNT_MSAT).map(hops2Ids) === Success(0 until 18)) - assert(Router.findRoute(g, nodes(0), nodes(19), DEFAULT_AMOUNT_MSAT).map(hops2Ids) === Success(0 until 19)) - assert(Router.findRoute(g, nodes(0), nodes(20), DEFAULT_AMOUNT_MSAT).map(hops2Ids) === Success(0 until 20)) - assert(Router.findRoute(g, nodes(0), nodes(21), DEFAULT_AMOUNT_MSAT).map(hops2Ids) === Failure(RouteNotFound)) + assert(Router.findRoute(g, nodes(0), nodes(18), DEFAULT_AMOUNT_MSAT, numRoutes = 1).map(hops2Ids) === Success(0 until 18)) + assert(Router.findRoute(g, nodes(0), nodes(19), DEFAULT_AMOUNT_MSAT, numRoutes = 1).map(hops2Ids) === Success(0 until 19)) + assert(Router.findRoute(g, nodes(0), nodes(20), DEFAULT_AMOUNT_MSAT, numRoutes = 1).map(hops2Ids) === Success(0 until 20)) + assert(Router.findRoute(g, nodes(0), nodes(21), DEFAULT_AMOUNT_MSAT, numRoutes = 1).map(hops2Ids) === Failure(RouteNotFound)) } test("ignore cheaper route when it has more than 20 hops") { @@ -577,7 +569,7 @@ class RouteCalculationSpec extends FunSuite { val g = makeGraph(updates2) - val route = Router.findRoute(g, nodes(0), nodes(49), DEFAULT_AMOUNT_MSAT) + val route = Router.findRoute(g, nodes(0), nodes(49), DEFAULT_AMOUNT_MSAT, numRoutes = 1) assert(route.map(hops2Ids) === Success(0 :: 1 :: 99 :: 48 :: Nil)) } @@ -593,10 +585,145 @@ class RouteCalculationSpec extends FunSuite { val g = makeGraph(updates) - val route1 = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT) + val route1 = Router.findRoute(g, a, e, DEFAULT_AMOUNT_MSAT, numRoutes = 1) assert(route1.map(hops2Ids) === Success(1 :: 2 :: 4 :: 5 :: Nil)) } + + /** + * + * +---+ +---+ +---+ + * | A +-----+ | B +----------> | C | + * +-+-+ | +-+-+ +-+-+ + * ^ | ^ | + * | | | | + * | v----> + | | + * +-+-+ <-+-+ +-+-+ + * | D +----------> | E +----------> | F | + * +---+ +---+ +---+ + * + */ + test("find the k-shortest paths in a graph, k=4") { + + val (a, b, c, d, e, f) = ( + PublicKey("02999fa724ec3c244e4da52b4a91ad421dc96c9a810587849cd4b2469313519c73"), //a + PublicKey("03f1cb1af20fe9ccda3ea128e27d7c39ee27375c8480f11a87c17197e97541ca6a"), //b + PublicKey("0358e32d245ff5f5a3eb14c78c6f69c67cea7846bdf9aeeb7199e8f6fbb0306484"), //c + PublicKey("029e059b6780f155f38e83601969919aae631ddf6faed58fe860c72225eb327d7c"), //d + PublicKey("02f38f4e37142cc05df44683a83e22dea608cf4691492829ff4cf99888c5ec2d3a"), //e + PublicKey("03fc5b91ce2d857f146fd9b986363374ffe04dc143d8bcd6d7664c8873c463cdfc") //f + ) + + + val edges = Seq( + makeUpdate(1L, d, a, 1, 0), + makeUpdate(2L, d, e, 1, 0), + makeUpdate(3L, a, e, 1, 0), + makeUpdate(4L, e, b, 1, 0), + makeUpdate(5L, e, f, 1, 0), + makeUpdate(6L, b, c, 1, 0), + makeUpdate(7L, c, f, 1, 0) + ).toMap + + val graph = DirectedGraph.makeGraph(edges) + + val fourShortestPaths = Graph.yenKshortestPaths(graph, d, f, DEFAULT_AMOUNT_MSAT, Set.empty, Set.empty, pathsToFind = 4) + + 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 + } + + test("find the k shortest path (wikipedia example)") { + val (c, d, e, f, g, h) = ( + PublicKey("02999fa724ec3c244e4da52b4a91ad421dc96c9a810587849cd4b2469313519c73"), //c + PublicKey("03f1cb1af20fe9ccda3ea128e27d7c39ee27375c8480f11a87c17197e97541ca6a"), //d + PublicKey("0358e32d245ff5f5a3eb14c78c6f69c67cea7846bdf9aeeb7199e8f6fbb0306484"), //e + PublicKey("029e059b6780f155f38e83601969919aae631ddf6faed58fe860c72225eb327d7c"), //f + PublicKey("02f38f4e37142cc05df44683a83e22dea608cf4691492829ff4cf99888c5ec2d3a"), //g + PublicKey("03fc5b91ce2d857f146fd9b986363374ffe04dc143d8bcd6d7664c8873c463cdfc") //h + ) + + + val edges = Seq( + makeUpdate(10L, c, e, 2, 0), + makeUpdate(20L, c, d, 3, 0), + makeUpdate(30L, d, f, 4, 5), // D- > F has a higher cost to distinguish it from the 2nd cheapest route + makeUpdate(40L, e, d, 1, 0), + makeUpdate(50L, e, f, 2, 0), + makeUpdate(60L, e, g, 3, 0), + makeUpdate(70L, f, g, 2, 0), + makeUpdate(80L, f, h, 1, 0), + makeUpdate(90L, g, h, 2, 0) + ) + + val graph = DirectedGraph().addEdges(edges) + + val twoShortestPaths = Graph.yenKshortestPaths(graph, c, h, DEFAULT_AMOUNT_MSAT, Set.empty, Set.empty, pathsToFind = 2) + + 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"){ + + val f = randomKey.publicKey + + // simple graph with only 2 possible paths from A to F + val edges = Seq( + makeUpdate(1L, a, b, 1, 0), + makeUpdate(2L, b, c, 1, 0), + makeUpdate(3L, c, f, 1, 0), + makeUpdate(4L, c, d, 1, 0), + makeUpdate(5L, d, e, 1, 0), + makeUpdate(6L, e, f, 1, 0) + ) + + val graph = DirectedGraph().addEdges(edges) + + //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, pathsToFind = 3) + + 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 allowed fee spread") { + + val f = randomKey.publicKey + + // A -> B -> C -> D has total cost of 10000005 + // A -> E -> C -> D has total cost of 11080003 !! + // A -> E -> F -> D has total cost of 10000006 + val g = makeGraph(List( + makeUpdate(1L, a, b, feeBaseMsat = 1, 0), + makeUpdate(4L, a, e, feeBaseMsat = 1, 0), + makeUpdate(2L, b, c, feeBaseMsat = 2, 0), + makeUpdate(3L, c, d, feeBaseMsat = 3, 0), + makeUpdate(5L, e, f, feeBaseMsat = 3, 0), + makeUpdate(6L, f, d, feeBaseMsat = 3, 0), + makeUpdate(7L, e, c, feeBaseMsat = 90000, 99000) + ).toMap) + + (for { _ <- 0 to 10 } yield Router.findRoute(g, a, d, DEFAULT_AMOUNT_MSAT, numRoutes = 3)).map { + case Failure(_) => assert(false) + case Success(someRoute) => + + val routeCost = Graph.pathCost(hops2Edges(someRoute), DEFAULT_AMOUNT_MSAT) + val allowedSpread = DEFAULT_AMOUNT_MSAT + (DEFAULT_AMOUNT_MSAT * Router.DEFAULT_ALLOWED_SPREAD) + + // over the three routes we could only get the 2 cheapest because the third is too expensive (over 10% of the cheapest) + assert(routeCost == 10000005 || routeCost == 10000006) + assert(routeCost < allowedSpread) + } + } } object RouteCalculationSpec { @@ -632,4 +759,6 @@ object RouteCalculationSpec { def hops2Ids(route: Seq[Hop]) = route.map(hop => hop.lastUpdate.shortChannelId.toLong) + def hops2Edges(route: Seq[Hop]) = route.map(hop => GraphEdge(ChannelDesc(hop.lastUpdate.shortChannelId, hop.nodeId, hop.nextNodeId), hop.lastUpdate)) + }