diff --git a/eclair-core/src/main/resources/reference.conf b/eclair-core/src/main/resources/reference.conf index 60334ef6aa..efe9b4f9d1 100644 --- a/eclair-core/src/main/resources/reference.conf +++ b/eclair-core/src/main/resources/reference.conf @@ -116,6 +116,7 @@ eclair { randomize-route-selection = true // when computing a route for a payment we randomize the final selection channel-exclude-duration = 60 seconds // when a temporary channel failure is returned, we exclude the channel from our payment routes for this duration broadcast-interval = 60 seconds // see BOLT #7 + network-stats-interval = 6 hours // frequency at which we refresh global network statistics (expensive operation) init-timeout = 5 minutes sync { 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 ecadd9dbc0..f9345f6868 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala @@ -243,6 +243,7 @@ object NodeParams { routerConf = RouterConf( channelExcludeDuration = FiniteDuration(config.getDuration("router.channel-exclude-duration").getSeconds, TimeUnit.SECONDS), routerBroadcastInterval = FiniteDuration(config.getDuration("router.broadcast-interval").getSeconds, TimeUnit.SECONDS), + networkStatsRefreshInterval = FiniteDuration(config.getDuration("router.network-stats-interval").getSeconds, TimeUnit.SECONDS), randomizeRouteSelection = config.getBoolean("router.randomize-route-selection"), requestNodeAnnouncements = config.getBoolean("router.sync.request-node-announcements"), encodingType = routerSyncEncodingType, 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 09dffdfc76..23a19e65a7 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 @@ -29,10 +29,21 @@ import scala.collection.mutable object Graph { // @formatter:off - // A compound weight for an edge, weight is obtained with (cost X factor),'cost' contains the actual amount+fees in millisatoshi, 'cltvCumulative' the total CLTV necessary to reach this edge + /** + * The cumulative weight of a set of edges (path in the graph). + * + * @param cost amount to send to the recipient + each edge's fees + * @param length number of edges in the path + * @param cltv sum of each edge's cltv + * @param weight cost multiplied by a factor based on heuristics (see [[WeightRatios]]). + */ case class RichWeight(cost: MilliSatoshi, length: Int, cltv: CltvExpiryDelta, weight: Double) extends Ordered[RichWeight] { - override def compare(that: RichWeight): Int = this.weight.compareTo(that.weight) + override def compare(that: RichWeight): Int = this.weight.compareTo(that.weight) } + /** + * We use heuristics to calculate the weight of an edge based on channel age, cltv delta and capacity. + * We favor older channels, with bigger capacity and small cltv delta. + */ case class WeightRatios(cltvDeltaFactor: Double, ageFactor: Double, capacityFactor: Double) { require(0 < cltvDeltaFactor + ageFactor + capacityFactor && cltvDeltaFactor + ageFactor + capacityFactor <= 1, "The sum of heuristics ratios must be between 0 and 1 (included)") } @@ -57,18 +68,17 @@ object Graph { } /** - * Yen's algorithm to find the k-shortest (loopless) paths in a graph, uses dijkstra as search algo. Is guaranteed to terminate finding + * Yen's algorithm to find the k-shortest (loop-less) 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 amount - * @param pathsToFind + * @param graph graph representing the whole network + * @param sourceNode sender node (payer) + * @param targetNode target node (final recipient) + * @param amount amount to send to the last node + * @param pathsToFind number of distinct paths to be returned * @param wr an object containing the ratios used to 'weight' edges when searching for the shortest path * @param currentBlockHeight the height of the chain tip (latest block) * @param boundaries a predicate function that can be used to impose limits on the outcome of the search - * @return */ def yenKshortestPaths(graph: DirectedGraph, sourceNode: PublicKey, @@ -175,7 +185,6 @@ object Graph { * @param boundaries a predicate function that can be used to impose limits on the outcome of the search * @return */ - def dijkstraShortestPath(g: DirectedGraph, sourceNode: PublicKey, targetNode: PublicKey, @@ -237,7 +246,6 @@ object Graph { boundaries(newMinimumKnownWeight) && // check if this neighbor edge would break off the 'boundaries' !ignoredEdges.contains(edge.desc) && !ignoredVertices.contains(neighbor) ) { - // we call containsKey first because "getOrDefault" is not available in JDK7 val neighborCost = weight.containsKey(neighbor) match { case false => RichWeight(MilliSatoshi(Long.MaxValue), Int.MaxValue, CltvExpiryDelta(Int.MaxValue), Double.MaxValue) @@ -332,7 +340,7 @@ object Graph { // Calculates the total cost of a path (amount + fees), direct channels with the source will have a cost of 0 (pay no fees) def pathWeight(path: Seq[GraphEdge], amountMsat: MilliSatoshi, isPartial: Boolean, currentBlockHeight: Long, wr: Option[WeightRatios]): RichWeight = { path.drop(if (isPartial) 0 else 1).foldRight(RichWeight(amountMsat, 0, CltvExpiryDelta(0), 0)) { (edge, prev) => - edgeWeight(edge, prev, false, currentBlockHeight, wr) + edgeWeight(edge, prev, isNeighborTarget = false, currentBlockHeight, wr) } } @@ -353,11 +361,6 @@ object Graph { /** * Normalize the given value between (0, 1). If the @param value is outside the min/max window we flatten it to something very close to the * extremes but always bigger than zero so it's guaranteed to never return zero - * - * @param value - * @param min - * @param max - * @return */ def normalize(value: Double, min: Double, max: Double) = { if (value <= min) 0.00001D @@ -367,7 +370,7 @@ object Graph { } /** - * A graph data structure that uses the adjacency lists, stores the incoming edges of the neighbors + * A graph data structure that uses an adjacency list, stores the incoming edges of the neighbors */ object GraphStructure { @@ -388,13 +391,12 @@ object Graph { } /** - * Adds and edge to the graph, if one of the two vertices is not found, it will be created. + * Adds an edge to the graph. If one of the two vertices is not found it will be created. * * @param edge the edge that is going to be added to the graph * @return a new graph containing this edge */ def addEdge(edge: GraphEdge): DirectedGraph = { - val vertexIn = edge.desc.a val vertexOut = edge.desc.b @@ -412,7 +414,7 @@ object Graph { * NB: this operation does NOT remove any vertex * * @param desc the channel description associated to the edge that will be removed - * @return + * @return a new graph without this edge */ def removeEdge(desc: ChannelDesc): DirectedGraph = { containsEdge(desc) match { @@ -426,7 +428,6 @@ object Graph { } /** - * @param edge * @return For edges to be considered equal they must have the same in/out vertices AND same shortChannelId */ def getEdge(edge: GraphEdge): Option[GraphEdge] = getEdge(edge.desc) @@ -440,7 +441,7 @@ object Graph { /** * @param keyA the key associated with the starting vertex * @param keyB the key associated with the ending vertex - * @return all the edges going from keyA --> keyB (there might be more than one if it refers to different shortChannelId) + * @return all the edges going from keyA --> keyB (there might be more than one if there are multiple channels) */ def getEdgesBetween(keyA: PublicKey, keyB: PublicKey): Seq[GraphEdge] = { vertices.get(keyB) match { @@ -450,20 +451,15 @@ object Graph { } /** - * The the incoming edges for vertex @param keyB - * - * @param keyB - * @return + * @param keyB the key associated with the target vertex + * @return all edges incoming to that vertex */ def getIncomingEdgesOf(keyB: PublicKey): Seq[GraphEdge] = { vertices.getOrElse(keyB, List.empty) } /** - * Removes a vertex and all it's associated edges (both incoming and outgoing) - * - * @param key - * @return + * Removes a vertex and all its associated edges (both incoming and outgoing) */ def removeVertex(key: PublicKey): DirectedGraph = { DirectedGraph(removeEdges(getIncomingEdgesOf(key).map(_.desc)).vertices - key) @@ -471,9 +467,6 @@ object Graph { /** * Adds a new vertex to the graph, starting with no edges - * - * @param key - * @return */ def addVertex(key: PublicKey): DirectedGraph = { vertices.get(key) match { @@ -485,8 +478,7 @@ object Graph { /** * Note this operation will traverse all edges in the graph (expensive) * - * @param key - * @return a list of the outgoing edges of vertex @param key, if the edge doesn't exists an empty list is returned + * @return a list of the outgoing edges of the given vertex. If the vertex doesn't exists an empty list is returned. */ def edgesOf(key: PublicKey): Seq[GraphEdge] = { edgeSet().filter(_.desc.a == key).toSeq @@ -503,13 +495,11 @@ object Graph { def edgeSet(): Iterable[GraphEdge] = vertices.values.flatten /** - * @param key * @return true if this graph contain a vertex with this key, false otherwise */ def containsVertex(key: PublicKey): Boolean = vertices.contains(key) /** - * @param desc * @return true if this edge desc is in the graph. For edges to be considered equal they must have the same in/out vertices AND same shortChannelId */ def containsEdge(desc: ChannelDesc): Boolean = { @@ -528,20 +518,25 @@ object Graph { object DirectedGraph { - // convenience constructors + // @formatter:off def apply(): DirectedGraph = new DirectedGraph(Map()) - def apply(key: PublicKey): DirectedGraph = new DirectedGraph(Map(key -> List.empty)) - def apply(edge: GraphEdge): DirectedGraph = new DirectedGraph(Map()).addEdge(edge.desc, edge.update) - def apply(edges: Seq[GraphEdge]): DirectedGraph = { DirectedGraph().addEdges(edges.map(e => (e.desc, e.update))) } + // @formatter:on - // optimized constructor + /** + * This is the recommended way of creating the network graph. + * We don't include private channels: they would bloat the graph without providing any value (if they are private + * they likely don't want to be involved in routing other people's payments). + * The only private channels we know are ours: we should check them to see if our destination can be reached in a + * single hop via a private channel before using the public network graph. + * + * @param channels map of all known public channels in the network. + */ def makeGraph(channels: SortedMap[ShortChannelId, PublicChannel]): DirectedGraph = { - // initialize the map with the appropriate size to avoid resizing during the graph initialization val mutableMap = new {} with mutable.HashMap[PublicKey, List[GraphEdge]] { override def initialSize: Int = channels.size + 1 @@ -560,7 +555,7 @@ object Graph { } } - def addDescToMap(desc: ChannelDesc, u: ChannelUpdate) = { + def addDescToMap(desc: ChannelDesc, u: ChannelUpdate): Unit = { mutableMap.put(desc.b, GraphEdge(desc, u) +: mutableMap.getOrElse(desc.b, List.empty[GraphEdge])) mutableMap.get(desc.a) match { case None => mutableMap += desc.a -> List.empty[GraphEdge] diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/router/NetworkStats.scala b/eclair-core/src/main/scala/fr/acinq/eclair/router/NetworkStats.scala new file mode 100644 index 0000000000..0321c0dd55 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/router/NetworkStats.scala @@ -0,0 +1,63 @@ +/* + * Copyright 2019 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.google.common.math.Quantiles.percentiles +import fr.acinq.bitcoin.Satoshi +import fr.acinq.eclair.wire.ChannelUpdate +import fr.acinq.eclair.{CltvExpiryDelta, MilliSatoshi} + +import scala.collection.JavaConverters._ + +/** + * Created by t-bast on 30/08/2019. + */ + +case class Stats[T](median: T, percentile5: T, percentile10: T, percentile25: T, percentile75: T, percentile90: T, percentile95: T) + +object Stats { + def apply[T](values: Seq[Long], fromDouble: Double => T): Stats[T] = { + require(values.nonEmpty, "can't compute stats on empty values") + val stats = percentiles().indexes(5, 10, 25, 50, 75, 90, 95).compute(values.map(java.lang.Long.valueOf).asJavaCollection) + Stats(fromDouble(stats.get(50)), fromDouble(stats.get(5)), fromDouble(stats.get(10)), fromDouble(stats.get(25)), fromDouble(stats.get(75)), fromDouble(stats.get(90)), fromDouble(stats.get(95))) + } +} + +case class NetworkStats(channels: Int, nodes: Int, capacity: Stats[Satoshi], cltvExpiryDelta: Stats[CltvExpiryDelta], feeBase: Stats[MilliSatoshi], feeProportional: Stats[Long]) + +object NetworkStats { + /** + * Computes various network statistics (expensive). + * Network statistics won't change noticeably very quickly, so this should not be re-computed too often. + */ + def apply(publicChannels: Seq[PublicChannel]): Option[NetworkStats] = { + // We need at least one channel update to be able to compute stats. + if (publicChannels.isEmpty || publicChannels.flatMap(pc => getChannelUpdateField(pc, _ => true)).isEmpty) { + None + } else { + val capacityStats = Stats(publicChannels.map(_.capacity.toLong), d => Satoshi(d.toLong)) + val cltvStats = Stats(publicChannels.flatMap(pc => getChannelUpdateField(pc, u => u.cltvExpiryDelta.toInt.toLong)), d => CltvExpiryDelta(d.toInt)) + val feeBaseStats = Stats(publicChannels.flatMap(pc => getChannelUpdateField(pc, u => u.feeBaseMsat.toLong)), d => MilliSatoshi(d.toLong)) + val feeProportionalStats = Stats(publicChannels.flatMap(pc => getChannelUpdateField(pc, u => u.feeProportionalMillionths)), d => d.toLong) + val nodes = publicChannels.flatMap(pc => pc.ann.nodeId1 :: pc.ann.nodeId2 :: Nil).toSet.size + Some(NetworkStats(publicChannels.size, nodes, capacityStats, cltvStats, feeBaseStats, feeProportionalStats)) + } + } + + private def getChannelUpdateField[T](pc: PublicChannel, f: ChannelUpdate => T): Seq[T] = (pc.update_1_opt.toSeq ++ pc.update_2_opt.toSeq).map(f) + +} 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 7e0bf08f8d..45bc52f198 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 @@ -33,7 +33,7 @@ import fr.acinq.eclair.io.Peer.{ChannelClosed, InvalidAnnouncement, InvalidSigna 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.{RichWeight, WeightRatios} +import fr.acinq.eclair.router.Graph.{RichWeight, RoutingHeuristics, WeightRatios} import fr.acinq.eclair.transactions.Scripts import fr.acinq.eclair.wire._ import shapeless.HNil @@ -46,11 +46,14 @@ import scala.concurrent.duration._ import scala.concurrent.{ExecutionContext, Promise} import scala.util.{Random, Try} -// @formatter:off +/** + * Created by PM on 24/05/2016. + */ case class RouterConf(randomizeRouteSelection: Boolean, channelExcludeDuration: FiniteDuration, routerBroadcastInterval: FiniteDuration, + networkStatsRefreshInterval: FiniteDuration, requestNodeAnnouncements: Boolean, encodingType: EncodingType, channelRangeChunkSize: Int, @@ -64,8 +67,8 @@ case class RouterConf(randomizeRouteSelection: Boolean, searchRatioChannelAge: Double, searchRatioChannelCapacity: Double) +// @formatter:off case class ChannelDesc(shortChannelId: ShortChannelId, a: PublicKey, b: PublicKey) - case class PublicChannel(ann: ChannelAnnouncement, fundingTxid: ByteVector32, capacity: Satoshi, update_1_opt: Option[ChannelUpdate], update_2_opt: Option[ChannelUpdate]) { update_1_opt.foreach(u => assert(Announcements.isNode1(u.channelFlags))) update_2_opt.foreach(u => assert(!Announcements.isNode1(u.channelFlags))) @@ -76,7 +79,6 @@ case class PublicChannel(ann: ChannelAnnouncement, fundingTxid: ByteVector32, ca def updateChannelUpdateSameSideAs(u: ChannelUpdate): PublicChannel = if (Announcements.isNode1(u.channelFlags)) copy(update_1_opt = Some(u)) else copy(update_2_opt = Some(u)) } - case class PrivateChannel(localNodeId: PublicKey, remoteNodeId: PublicKey, update_1_opt: Option[ChannelUpdate], update_2_opt: Option[ChannelUpdate]) { val (nodeId1, nodeId2) = if (Announcements.isNode1(localNodeId, remoteNodeId)) (localNodeId, remoteNodeId) else (remoteNodeId, localNodeId) @@ -86,8 +88,9 @@ case class PrivateChannel(localNodeId: PublicKey, remoteNodeId: PublicKey, updat def updateChannelUpdateSameSideAs(u: ChannelUpdate): PrivateChannel = if (Announcements.isNode1(u.channelFlags)) copy(update_1_opt = Some(u)) else copy(update_2_opt = Some(u)) } +// @formatter:on -case class AssistedChannel(extraHop: ExtraHop, nextNodeId: PublicKey) +case class AssistedChannel(extraHop: ExtraHop, nextNodeId: PublicKey, htlcMaximum: MilliSatoshi) case class Hop(nodeId: PublicKey, nextNodeId: PublicKey, lastUpdate: ChannelUpdate) @@ -107,11 +110,16 @@ case class RouteResponse(hops: Seq[Hop], ignoreNodes: Set[PublicKey], ignoreChan require(hops.nonEmpty, "route cannot be empty") } -case class ExcludeChannel(desc: ChannelDesc) // this is used when we get a TemporaryChannelFailure, to give time for the channel to recover (note that exclusions are directed) +// @formatter:off +/** This is used when we get a TemporaryChannelFailure, to give time for the channel to recover (note that exclusions are directed) */ +case class ExcludeChannel(desc: ChannelDesc) case class LiftChannelExclusion(desc: ChannelDesc) +// @formatter:on case class SendChannelQuery(remoteNodeId: PublicKey, to: ActorRef, flags_opt: Option[QueryChannelRangeTlv]) +case object GetNetworkStats + case object GetRoutingState case class RoutingState(channels: Iterable[PublicChannel], nodes: Iterable[NodeAnnouncement]) @@ -126,6 +134,7 @@ case class Sync(pending: List[RoutingMessage], total: Int) case class Data(nodes: Map[PublicKey, NodeAnnouncement], channels: SortedMap[ShortChannelId, PublicChannel], + stats: Option[NetworkStats], stash: Stash, rebroadcast: Rebroadcast, awaiting: Map[ChannelAnnouncement, Seq[ActorRef]], // note: this is a seq because we want to preserve order: first actor is the one who we need to send a tcp-ack when validation is done @@ -136,20 +145,15 @@ case class Data(nodes: Map[PublicKey, NodeAnnouncement], // for which we have not yet received an 'end' message ) +// @formatter:off sealed trait State - case object NORMAL extends State case object TickBroadcast - case object TickPruneStaleChannels - +case object TickComputeNetworkStats // @formatter:on -/** - * Created by PM on 24/05/2016. - */ - class Router(val nodeParams: NodeParams, watcher: ActorRef, initialized: Option[Promise[Done]] = None) extends FSMDiagnosticActorLogging[State, Data] { import Router._ @@ -164,6 +168,7 @@ class Router(val nodeParams: NodeParams, watcher: ActorRef, initialized: Option[ setTimer(TickBroadcast.toString, TickBroadcast, nodeParams.routerConf.routerBroadcastInterval, repeat = true) setTimer(TickPruneStaleChannels.toString, TickPruneStaleChannels, 1 hour, repeat = true) + setTimer(TickComputeNetworkStats.toString, TickComputeNetworkStats, nodeParams.routerConf.networkStatsRefreshInterval, repeat = true) val defaultRouteParams = getDefaultRouteParams(nodeParams.routerConf) @@ -177,7 +182,7 @@ class Router(val nodeParams: NodeParams, watcher: ActorRef, initialized: Option[ val initChannels = channels // this will be used to calculate routes val graph = DirectedGraph.makeGraph(initChannels) - val initNodes = nodes.map(n => (n.nodeId -> n)).toMap + val initNodes = nodes.map(n => n.nodeId -> n).toMap // send events for remaining channels/nodes context.system.eventStream.publish(ChannelsDiscovered(initChannels.values.map(pc => SingleChannelDiscovered(pc.ann, pc.capacity)))) context.system.eventStream.publish(ChannelUpdatesReceived(initChannels.values.flatMap(pc => pc.update_1_opt ++ pc.update_2_opt ++ Nil))) @@ -199,7 +204,7 @@ class Router(val nodeParams: NodeParams, watcher: ActorRef, initialized: Option[ log.info(s"initialization completed, ready to process messages") Try(initialized.map(_.success(Done))) - startWith(NORMAL, Data(initNodes, initChannels, Stash(Map.empty, Map.empty), rebroadcast = Rebroadcast(channels = Map.empty, updates = Map.empty, nodes = Map.empty), awaiting = Map.empty, privateChannels = Map.empty, excludedChannels = Set.empty, graph, sync = Map.empty)) + startWith(NORMAL, Data(initNodes, initChannels, None, Stash(Map.empty, Map.empty), rebroadcast = Rebroadcast(channels = Map.empty, updates = Map.empty, nodes = Map.empty), awaiting = Map.empty, privateChannels = Map.empty, excludedChannels = Set.empty, graph, sync = Map.empty)) } when(NORMAL) { @@ -252,14 +257,26 @@ class Router(val nodeParams: NodeParams, watcher: ActorRef, initialized: Option[ stay } + case Event(SyncProgress(progress), d: Data) => + if (d.stats.isEmpty && progress == 1.0 && d.channels.nonEmpty) { + log.info("initial routing sync done: computing network statistics") + stay using d.copy(stats = NetworkStats(d.channels.values.toSeq)) + } else { + stay + } + case Event(GetRoutingState, d: Data) => log.info(s"getting valid announcements for $sender") sender ! RoutingState(d.channels.values, d.nodes.values) stay + case Event(GetNetworkStats, d: Data) => + sender ! d.stats + stay + case Event(v@ValidateResult(c, _), d0) => d0.awaiting.get(c) match { - case Some(origin +: others) => origin ! TransportHandler.ReadAck(c) // now we can acknowledge the message, we only need to do it for the first peer that sent us the announcement + case Some(origin +: _) => origin ! TransportHandler.ReadAck(c) // now we can acknowledge the message, we only need to do it for the first peer that sent us the announcement case _ => () } log.info("got validation result for shortChannelId={} (awaiting={} stash.nodes={} stash.updates={})", c.shortChannelId, d0.awaiting.size, d0.stash.nodes.size, d0.stash.updates.size) @@ -356,7 +373,7 @@ class Router(val nodeParams: NodeParams, watcher: ActorRef, initialized: Option[ context.system.eventStream.publish(ChannelLost(shortChannelId)) lostNodes.foreach { - case nodeId => + nodeId => log.info("pruning nodeId={} (spent)", nodeId) db.removeNode(nodeId) context.system.eventStream.publish(NodeLost(nodeId)) @@ -373,6 +390,10 @@ class Router(val nodeParams: NodeParams, watcher: ActorRef, initialized: Option[ stay using d.copy(rebroadcast = Rebroadcast(channels = Map.empty, updates = Map.empty, nodes = Map.empty)) } + case Event(TickComputeNetworkStats, d) if d.channels.nonEmpty => + log.info("re-computing network statistics") + stay using d.copy(stats = NetworkStats(d.channels.values.toSeq)) + case Event(TickPruneStaleChannels, d) => // first we select channels that we will prune val staleChannels = getStaleChannels(d.channels.values) @@ -400,7 +421,7 @@ class Router(val nodeParams: NodeParams, watcher: ActorRef, initialized: Option[ val graph1 = d.graph.removeEdges(staleChannelsToRemove) staleNodes.foreach { - case nodeId => + nodeId => log.info("pruning nodeId={} (stale)", nodeId) db.removeNode(nodeId) context.system.eventStream.publish(NodeLost(nodeId)) @@ -448,8 +469,8 @@ class Router(val nodeParams: NodeParams, watcher: ActorRef, initialized: Option[ case Event(RouteRequest(start, end, amount, assistedRoutes, ignoreNodes, ignoreChannels, params_opt), d) => // we convert extra routing info provided in the payment request to fake channel_update // it takes precedence over all other channel_updates we know - val assistedChannels: Map[ShortChannelId, AssistedChannel] = assistedRoutes.flatMap(toAssistedChannels(_, end)).toMap - val extraEdges = assistedChannels.values.map(ac => GraphEdge(ChannelDesc(ac.extraHop.shortChannelId, ac.extraHop.nodeId, ac.nextNodeId), toFakeUpdate(ac.extraHop))).toSet + val assistedChannels: Map[ShortChannelId, AssistedChannel] = assistedRoutes.flatMap(toAssistedChannels(_, end, amount)).toMap + val extraEdges = assistedChannels.values.map(ac => GraphEdge(ChannelDesc(ac.extraHop.shortChannelId, ac.extraHop.nodeId, ac.nextNodeId), toFakeUpdate(ac.extraHop, ac.htlcMaximum))).toSet val ignoredEdges = ignoreChannels ++ d.excludedChannels val params = params_opt.getOrElse(defaultRouteParams) val routesToFind = if (params.randomize) DEFAULT_ROUTES_COUNT else 1 @@ -481,7 +502,7 @@ class Router(val nodeParams: NodeParams, watcher: ActorRef, initialized: Option[ stay using d.copy(sync = d.sync - remoteNodeId) // Warning: order matters here, this must be the first match for HasChainHash messages ! - case Event(PeerRoutingMessage(_, _, routingMessage: HasChainHash), d) if routingMessage.chainHash != nodeParams.chainHash => + case Event(PeerRoutingMessage(_, _, routingMessage: HasChainHash), _) if routingMessage.chainHash != nodeParams.chainHash => sender ! TransportHandler.ReadAck(routingMessage) log.warning("message {} for wrong chain {}, we're on {}", routingMessage, routingMessage.chainHash, nodeParams.chainHash) stay @@ -601,10 +622,12 @@ class Router(val nodeParams: NodeParams, watcher: ActorRef, initialized: Option[ val (sync1, replynow_opt) = addToSync(d.sync, remoteNodeId, replies) // we only send a reply right away if there were no pending requests replynow_opt.foreach(transport ! _) - context.system.eventStream.publish(syncProgress(sync1)) + val progress = syncProgress(sync1) + context.system.eventStream.publish(progress) + self ! progress stay using d.copy(sync = sync1) - case Event(PeerRoutingMessage(transport, _, routingMessage@QueryShortChannelIds(chainHash, shortChannelIds, queryFlags_opt)), d) => + case Event(PeerRoutingMessage(transport, _, routingMessage@QueryShortChannelIds(chainHash, shortChannelIds, _)), d) => sender ! TransportHandler.ReadAck(routingMessage) val flags = routingMessage.queryFlags_opt.map(_.array).getOrElse(List.empty[Long]) @@ -650,7 +673,9 @@ class Router(val nodeParams: NodeParams, watcher: ActorRef, initialized: Option[ } case _ => d.sync } - context.system.eventStream.publish(syncProgress(sync1)) + val progress = syncProgress(sync1) + context.system.eventStream.publish(progress) + self ! progress stay using d.copy(sync = sync1) } @@ -814,18 +839,25 @@ object Router { def props(nodeParams: NodeParams, watcher: ActorRef, initialized: Option[Promise[Done]] = None) = Props(new Router(nodeParams, watcher, initialized)) - def toFakeUpdate(extraHop: ExtraHop): ChannelUpdate = - // the `direction` bit in flags will not be accurate but it doesn't matter because it is not used - // what matters is that the `disable` bit is 0 so that this update doesn't get filtered out - ChannelUpdate(signature = ByteVector64.Zeroes, chainHash = ByteVector32.Zeroes, extraHop.shortChannelId, Platform.currentTime.milliseconds.toSeconds, messageFlags = 0, channelFlags = 0, extraHop.cltvExpiryDelta, htlcMinimumMsat = 0 msat, extraHop.feeBase, extraHop.feeProportionalMillionths, None) - + def toFakeUpdate(extraHop: ExtraHop, htlcMaximum: MilliSatoshi): ChannelUpdate = { + // the `direction` bit in flags will not be accurate but it doesn't matter because it is not used + // what matters is that the `disable` bit is 0 so that this update doesn't get filtered out + ChannelUpdate(signature = ByteVector64.Zeroes, chainHash = ByteVector32.Zeroes, extraHop.shortChannelId, Platform.currentTime.milliseconds.toSeconds, messageFlags = 1, channelFlags = 0, extraHop.cltvExpiryDelta, htlcMinimumMsat = 0 msat, extraHop.feeBase, extraHop.feeProportionalMillionths, Some(htlcMaximum)) + } - def toAssistedChannels(extraRoute: Seq[ExtraHop], targetNodeId: PublicKey): Map[ShortChannelId, AssistedChannel] = { + def toAssistedChannels(extraRoute: Seq[ExtraHop], targetNodeId: PublicKey, amount: MilliSatoshi): Map[ShortChannelId, AssistedChannel] = { // BOLT 11: "For each entry, the pubkey is the node ID of the start of the channel", and the last node is the destination + // The invoice doesn't explicitly specify the channel's htlcMaximumMsat, but we can safely assume that the channel + // should be able to route the payment, so we'll compute an htlcMaximumMsat accordingly. + // We could also get the channel capacity from the blockchain (since we have the shortChannelId) but that's more expensive. + // We also need to make sure the channel isn't excluded by our heuristics. + val lastChannelCapacity = amount.max(RoutingHeuristics.CAPACITY_CHANNEL_LOW) val nextNodeIds = extraRoute.map(_.nodeId).drop(1) :+ targetNodeId - extraRoute.zip(nextNodeIds).map { - case (extraHop: ExtraHop, nextNodeId) => extraHop.shortChannelId -> AssistedChannel(extraHop, nextNodeId) - }.toMap + extraRoute.zip(nextNodeIds).reverse.foldLeft((lastChannelCapacity, Map.empty[ShortChannelId, AssistedChannel])) { + case ((amount, acs), (extraHop: ExtraHop, nextNodeId)) => + val nextAmount = amount + nodeFee(extraHop.feeBase, extraHop.feeProportionalMillionths, amount) + (nextAmount, acs + (extraHop.shortChannelId -> AssistedChannel(extraHop, nextNodeId, nextAmount))) + }._2 } def getDesc(u: ChannelUpdate, channel: ChannelAnnouncement): ChannelDesc = { @@ -858,7 +890,6 @@ object Router { * AND * (2) has no channel_update younger than 2 weeks * - * @param channel * @param update1_opt update corresponding to one side of the channel, if we have it * @param update2_opt update corresponding to the other side of the channel, if we have it * @return @@ -868,7 +899,7 @@ object Router { // but we don't want to prune brand new channels for which we didn't yet receive a channel update, so we keep them as long as they are less than 2 weeks (2016 blocks) old val staleThresholdBlocks = Globals.blockCount.get() - 2016 val TxCoordinates(blockHeight, _, _) = ShortChannelId.coordinates(channel.shortChannelId) - blockHeight < staleThresholdBlocks && update1_opt.map(isStale).getOrElse(true) && update2_opt.map(isStale).getOrElse(true) + blockHeight < staleThresholdBlocks && update1_opt.forall(isStale) && update2_opt.forall(isStale) } def getStaleChannels(channels: Iterable[PublicChannel]): Iterable[PublicChannel] = channels.filter(data => isStale(data.ann, data.update_1_opt, data.update_2_opt)) @@ -966,8 +997,8 @@ object Router { log.warning("received query for shortChannelId={} that we don't have", head) loop(tail, flags.drop(1), numca, numcu, nodesSent) case head :: tail => - var numca1 = numca - var numcu1 = numcu + val numca1 = numca + val numcu1 = numcu var sent1 = nodesSent val pc = channels(head) val flag_opt = flags.headOption @@ -1013,11 +1044,10 @@ object Router { /** * Returns overall progress on synchronization * - * @param sync * @return a sync progress indicator (1 means fully synced) */ def syncProgress(sync: Map[PublicKey, Sync]): SyncProgress = { - //NB: progress is in terms of requests, not individual channels + // NB: progress is in terms of requests, not individual channels val (pending, total) = sync.foldLeft((0, 0)) { case ((p, t), (_, sync)) => (p + sync.pending.size, t + sync.total) } @@ -1065,9 +1095,6 @@ object Router { /** * Have to split ids because otherwise message could be too big * there could be several reply_channel_range messages for a single query - * - * @param shortChannelIds - * @return */ def split(shortChannelIds: SortedSet[ShortChannelId], channelRangeChunkSize: Int): List[ShortChannelIdsChunk] = { // this algorithm can split blocks (meaning that we can in theory generate several replies with the same first_block/num_blocks @@ -1114,7 +1141,7 @@ object Router { // Max allowed CLTV for a route val DEFAULT_ROUTE_MAX_CLTV = CltvExpiryDelta(1008) - // The default amount of routes we'll search for when findRoute is called + // The default number of routes we'll search for when findRoute is called with randomize = true val DEFAULT_ROUTES_COUNT = 3 def getDefaultRouteParams(routerConf: RouterConf) = RouteParams( @@ -1137,9 +1164,9 @@ object Router { * 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. * - * @param g - * @param localNodeId - * @param targetNodeId + * @param g graph of the whole network + * @param localNodeId sender node (payer) + * @param targetNodeId target node (final recipient) * @param amount 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 @@ -1189,6 +1216,7 @@ object Router { } // At this point 'foundRoutes' cannot be empty - Random.shuffle(foundRoutes).head.path.map(graphEdgeToHop) + val randomizedRoutes = if (routeParams.randomize) Random.shuffle(foundRoutes) else foundRoutes + randomizedRoutes.head.path.map(graphEdgeToHop) } } 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 fb4838d4fa..ae1d63138a 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala @@ -108,6 +108,7 @@ object TestConstants { randomizeRouteSelection = false, channelExcludeDuration = 60 seconds, routerBroadcastInterval = 5 seconds, + networkStatsRefreshInterval = 1 hour, requestNodeAnnouncements = true, encodingType = EncodingType.COMPRESSED_ZLIB, channelRangeChunkSize = 20, @@ -183,6 +184,7 @@ object TestConstants { randomizeRouteSelection = false, channelExcludeDuration = 60 seconds, routerBroadcastInterval = 5 seconds, + networkStatsRefreshInterval = 1 hour, requestNodeAnnouncements = true, encodingType = EncodingType.UNCOMPRESSED, channelRangeChunkSize = 20, diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/router/NetworkStatsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/router/NetworkStatsSpec.scala new file mode 100644 index 0000000000..78b6945818 --- /dev/null +++ b/eclair-core/src/test/scala/fr/acinq/eclair/router/NetworkStatsSpec.scala @@ -0,0 +1,95 @@ +/* + * Copyright 2019 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 fr.acinq.bitcoin.Crypto.PublicKey +import fr.acinq.bitcoin.Satoshi +import fr.acinq.eclair.wire.{ChannelAnnouncement, ChannelUpdate} +import fr.acinq.eclair.{CltvExpiryDelta, LongToBtcAmount, MilliSatoshi, ShortChannelId, randomBytes32, randomBytes64, randomKey} +import org.scalatest.FunSuite + +import scala.util.Random + +/** + * Created by t-bast on 30/08/2019. + */ + +class NetworkStatsSpec extends FunSuite { + + import NetworkStatsSpec._ + + test("network data missing") { + assert(NetworkStats(Nil) === None) + assert(NetworkStats(Seq( + PublicChannel(fakeChannelAnnouncement(randomKey.publicKey, randomKey.publicKey), randomBytes32, 10 sat, None, None), + PublicChannel(fakeChannelAnnouncement(randomKey.publicKey, randomKey.publicKey), randomBytes32, 15 sat, None, None) + )) === None) + } + + test("small network") { + val nodes = Seq.fill(6)(randomKey.publicKey) + val channels = Seq( + PublicChannel(fakeChannelAnnouncement(nodes(0), nodes(1)), randomBytes32, 10 sat, Some(fakeChannelUpdate1(CltvExpiryDelta(10), 10 msat, 10)), Some(fakeChannelUpdate2(CltvExpiryDelta(15), 15 msat, 15))), + PublicChannel(fakeChannelAnnouncement(nodes(1), nodes(2)), randomBytes32, 20 sat, None, Some(fakeChannelUpdate2(CltvExpiryDelta(25), 25 msat, 25))), + PublicChannel(fakeChannelAnnouncement(nodes(2), nodes(3)), randomBytes32, 30 sat, Some(fakeChannelUpdate1(CltvExpiryDelta(30), 30 msat, 30)), Some(fakeChannelUpdate2(CltvExpiryDelta(35), 35 msat, 35))), + PublicChannel(fakeChannelAnnouncement(nodes(3), nodes(4)), randomBytes32, 40 sat, Some(fakeChannelUpdate1(CltvExpiryDelta(40), 40 msat, 40)), None), + PublicChannel(fakeChannelAnnouncement(nodes(4), nodes(5)), randomBytes32, 50 sat, Some(fakeChannelUpdate1(CltvExpiryDelta(50), 50 msat, 50)), Some(fakeChannelUpdate2(CltvExpiryDelta(55), 55 msat, 55))) + ) + val Some(stats) = NetworkStats(channels) + assert(stats.channels === 5) + assert(stats.nodes === 6) + assert(stats.capacity === Stats(30 sat, 12 sat, 14 sat, 20 sat, 40 sat, 46 sat, 48 sat)) + assert(stats.cltvExpiryDelta === Stats(CltvExpiryDelta(32), CltvExpiryDelta(11), CltvExpiryDelta(13), CltvExpiryDelta(22), CltvExpiryDelta(42), CltvExpiryDelta(51), CltvExpiryDelta(53))) + assert(stats.feeBase === Stats(32 msat, 11 msat, 13 msat, 22 msat, 42 msat, 51 msat, 53 msat)) + assert(stats.feeProportional === Stats(32, 11, 13, 22, 42, 51, 53)) + } + + test("intermediate network") { + val rand = new Random() + val nodes = Seq.fill(100)(randomKey.publicKey) + val channels = Seq.fill(500)(PublicChannel( + fakeChannelAnnouncement(nodes(rand.nextInt(nodes.size)), nodes(rand.nextInt(nodes.size))), + randomBytes32, + Satoshi(1000 + rand.nextInt(10000)), + Some(fakeChannelUpdate1(CltvExpiryDelta(12 + rand.nextInt(144)), MilliSatoshi(21000 + rand.nextInt(79000)), rand.nextInt(1000))), + Some(fakeChannelUpdate2(CltvExpiryDelta(12 + rand.nextInt(144)), MilliSatoshi(21000 + rand.nextInt(79000)), rand.nextInt(1000))) + )) + val Some(stats) = NetworkStats(channels) + assert(stats.channels === 500) + assert(stats.nodes <= 100) + assert(1000.sat <= stats.capacity.median && stats.capacity.median <= 11000.sat) + assert(stats.feeBase.percentile10 >= 21000.msat) + assert(stats.feeProportional.median <= 1000) + } + +} + +object NetworkStatsSpec { + + def fakeChannelAnnouncement(local: PublicKey, remote: PublicKey): ChannelAnnouncement = { + Announcements.makeChannelAnnouncement(randomBytes32, ShortChannelId(42), local, remote, randomKey.publicKey, randomKey.publicKey, randomBytes64, randomBytes64, randomBytes64, randomBytes64) + } + + def fakeChannelUpdate1(cltv: CltvExpiryDelta, feeBase: MilliSatoshi, feeProportional: Long): ChannelUpdate = { + ChannelUpdate(randomBytes64, randomBytes32, ShortChannelId(42), 0, 0, 0, cltv, 1 msat, feeBase, feeProportional, None) + } + + def fakeChannelUpdate2(cltv: CltvExpiryDelta, feeBase: MilliSatoshi, feeProportional: Long): ChannelUpdate = { + ChannelUpdate(randomBytes64, randomBytes32, ShortChannelId(42), 0, 0, 1, cltv, 1 msat, feeBase, feeProportional, None) + } + +} \ No newline at end of file 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 1fedf2ab2c..ec467dad57 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 @@ -21,10 +21,10 @@ import fr.acinq.bitcoin.{Block, ByteVector32, ByteVector64, Satoshi} 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.{RichWeight, WeightRatios} +import fr.acinq.eclair.router.Graph.{RichWeight, RoutingHeuristics, WeightRatios} import fr.acinq.eclair.transactions.Transactions import fr.acinq.eclair.wire._ -import fr.acinq.eclair.{CltvExpiryDelta, Globals, LongToBtcAmount, MilliSatoshi, ShortChannelId, randomKey} +import fr.acinq.eclair.{CltvExpiryDelta, Globals, LongToBtcAmount, MilliSatoshi, ShortChannelId, ToMilliSatoshiConversion, randomKey} import org.scalatest.FunSuite import scodec.bits._ @@ -42,7 +42,6 @@ class RouteCalculationSpec extends FunSuite { val (a, b, c, d, e, f) = (randomKey.publicKey, randomKey.publicKey, randomKey.publicKey, randomKey.publicKey, randomKey.publicKey, randomKey.publicKey) test("calculate simple route") { - val updates = List( makeUpdate(1L, a, b, 1 msat, 10, cltvDelta = CltvExpiryDelta(1)), makeUpdate(2L, b, c, 1 msat, 10, cltvDelta = CltvExpiryDelta(1)), @@ -58,7 +57,6 @@ class RouteCalculationSpec extends FunSuite { } test("check fee against max pct properly") { - // fee is acceptable is it is either // - below our maximum fee base // - below our maximum fraction of the paid amount @@ -82,7 +80,6 @@ class RouteCalculationSpec extends FunSuite { } test("calculate the shortest path (correct fees)") { - val (a, b, c, d, e, f) = ( PublicKey(hex"02999fa724ec3c244e4da52b4a91ad421dc96c9a810587849cd4b2469313519c73"), // a: source PublicKey(hex"03f1cb1af20fe9ccda3ea128e27d7c39ee27375c8480f11a87c17197e97541ca6a"), @@ -116,7 +113,7 @@ class RouteCalculationSpec extends FunSuite { val Success(route) = Router.findRoute(graph, a, d, amount, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS) - val totalCost = Graph.pathWeight(hops2Edges(route), amount, false, 0, None).cost + val totalCost = Graph.pathWeight(hops2Edges(route), amount, isPartial = false, 0, None).cost assert(hops2Ids(route) === 4 :: 5 :: 6 :: Nil) assert(totalCost === expectedCost) @@ -146,7 +143,6 @@ class RouteCalculationSpec extends FunSuite { } test("calculate simple route (add and remove edges") { - val updates = List( makeUpdate(1L, a, b, 0 msat, 0), makeUpdate(2L, b, c, 0 msat, 0), @@ -165,7 +161,6 @@ class RouteCalculationSpec extends FunSuite { } test("calculate the shortest path (hardcoded nodes)") { - val (f, g, h, i) = ( PublicKey(hex"02999fa724ec3c244e4da52b4a91ad421dc96c9a810587849cd4b2469313519c73"), // source PublicKey(hex"03f1cb1af20fe9ccda3ea128e27d7c39ee27375c8480f11a87c17197e97541ca6a"), @@ -188,7 +183,6 @@ class RouteCalculationSpec extends FunSuite { } test("calculate the shortest path (select direct channel)") { - val (f, g, h, i) = ( PublicKey(hex"02999fa724ec3c244e4da52b4a91ad421dc96c9a810587849cd4b2469313519c73"), // source PublicKey(hex"03f1cb1af20fe9ccda3ea128e27d7c39ee27375c8480f11a87c17197e97541ca6a"), @@ -252,7 +246,6 @@ class RouteCalculationSpec extends FunSuite { } 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 @@ -274,7 +267,6 @@ class RouteCalculationSpec extends FunSuite { } test("calculate longer but cheaper route") { - val updates = List( makeUpdate(1L, a, b, 0 msat, 0), makeUpdate(2L, b, c, 0 msat, 0), @@ -290,7 +282,6 @@ class RouteCalculationSpec extends FunSuite { } test("no local channels") { - val updates = List( makeUpdate(2L, b, c, 0 msat, 0), makeUpdate(4L, d, e, 0 msat, 0) @@ -303,7 +294,6 @@ class RouteCalculationSpec extends FunSuite { } test("route not found") { - val updates = List( makeUpdate(1L, a, b, 0 msat, 0), makeUpdate(2L, b, c, 0 msat, 0), @@ -317,7 +307,6 @@ class RouteCalculationSpec extends FunSuite { } test("route not found (source OR target node not connected)") { - val updates = List( makeUpdate(2L, b, c, 0 msat, 0), makeUpdate(4L, c, d, 0 msat, 0) @@ -330,7 +319,6 @@ class RouteCalculationSpec extends FunSuite { } test("route not found (amount too high OR too low)") { - val highAmount = DEFAULT_AMOUNT_MSAT * 10 val lowAmount = DEFAULT_AMOUNT_MSAT / 10 @@ -354,7 +342,6 @@ class RouteCalculationSpec extends FunSuite { } test("route to self") { - val updates = List( makeUpdate(1L, a, b, 0 msat, 0), makeUpdate(2L, b, c, 0 msat, 0), @@ -368,7 +355,6 @@ class RouteCalculationSpec extends FunSuite { } test("route to immediate neighbor") { - val updates = List( makeUpdate(1L, a, b, 0 msat, 0), makeUpdate(2L, b, c, 0 msat, 0), @@ -402,7 +388,6 @@ class RouteCalculationSpec extends FunSuite { } test("calculate route and return metadata") { - val DUMMY_SIG = Transactions.PlaceHolderSig val uab = ChannelUpdate(DUMMY_SIG, Block.RegtestGenesisBlock.hash, ShortChannelId(1L), 0L, 0, 0, CltvExpiryDelta(1), 42 msat, 2500 msat, 140, None) @@ -432,31 +417,26 @@ class RouteCalculationSpec extends FunSuite { assert(hops === Hop(a, b, uab) :: Hop(b, c, ubc) :: Hop(c, d, ucd) :: Hop(d, e, ude) :: Nil) } - test("convert extra hops to channel_update") { + test("convert extra hops to assisted channels") { val a = randomKey.publicKey val b = randomKey.publicKey val c = randomKey.publicKey val d = randomKey.publicKey val e = randomKey.publicKey - val extraHop1 = ExtraHop(a, ShortChannelId(1), 10 msat, 11, CltvExpiryDelta(12)) - val extraHop2 = ExtraHop(b, ShortChannelId(2), 20 msat, 21, CltvExpiryDelta(22)) - val extraHop3 = ExtraHop(c, ShortChannelId(3), 30 msat, 31, CltvExpiryDelta(32)) - val extraHop4 = ExtraHop(d, ShortChannelId(4), 40 msat, 41, CltvExpiryDelta(42)) - + val extraHop1 = ExtraHop(a, ShortChannelId(1), 12.sat.toMilliSatoshi, 10000, CltvExpiryDelta(12)) + val extraHop2 = ExtraHop(b, ShortChannelId(2), 200.sat.toMilliSatoshi, 0, CltvExpiryDelta(22)) + val extraHop3 = ExtraHop(c, ShortChannelId(3), 150.sat.toMilliSatoshi, 0, CltvExpiryDelta(32)) + val extraHop4 = ExtraHop(d, ShortChannelId(4), 50.sat.toMilliSatoshi, 0, CltvExpiryDelta(42)) val extraHops = extraHop1 :: extraHop2 :: extraHop3 :: extraHop4 :: Nil - val fakeUpdates: Map[ShortChannelId, ExtraHop] = Router.toAssistedChannels(extraHops, e).map { case (shortChannelId, assistedChannel) => - (shortChannelId, assistedChannel.extraHop) - } - - assert(fakeUpdates == Map( - extraHop1.shortChannelId -> extraHop1, - extraHop2.shortChannelId -> extraHop2, - extraHop3.shortChannelId -> extraHop3, - extraHop4.shortChannelId -> extraHop4 - )) + val amount = 900 sat // below RoutingHeuristics.CAPACITY_CHANNEL_LOW + val assistedChannels = Router.toAssistedChannels(extraHops, e, amount.toMilliSatoshi) + assert(assistedChannels(extraHop4.shortChannelId) === AssistedChannel(extraHop4, e, 1050.sat.toMilliSatoshi)) + assert(assistedChannels(extraHop3.shortChannelId) === AssistedChannel(extraHop3, d, 1200.sat.toMilliSatoshi)) + assert(assistedChannels(extraHop2.shortChannelId) === AssistedChannel(extraHop2, c, 1400.sat.toMilliSatoshi)) + assert(assistedChannels(extraHop1.shortChannelId) === AssistedChannel(extraHop1, b, 1426.sat.toMilliSatoshi)) } test("blacklist routes") { @@ -502,7 +482,6 @@ class RouteCalculationSpec extends FunSuite { assert(route1.map(hops2Ids) === Success(1 :: 2 :: 3 :: 4 :: Nil)) } - test("verify that extra hops takes precedence over known channels") { val updates = List( makeUpdate(1L, a, b, 10 msat, 10), @@ -527,7 +506,6 @@ class RouteCalculationSpec extends FunSuite { } test("compute ignored channels") { - val f = randomKey.publicKey val g = randomKey.publicKey val h = randomKey.publicKey @@ -558,13 +536,12 @@ class RouteCalculationSpec extends FunSuite { ).toMap val publicChannels = channels.map { case (shortChannelId, announcement) => - val (_, update) = updates.find{ case (d, u) => d.shortChannelId == shortChannelId}.get + val (_, update) = updates.find { case (d, _) => d.shortChannelId == shortChannelId }.get val (update_1_opt, update_2_opt) = if (Announcements.isNode1(update.channelFlags)) (Some(update), None) else (None, Some(update)) val pc = PublicChannel(announcement, ByteVector32.Zeroes, Satoshi(1000), update_1_opt, update_2_opt) (shortChannelId, pc) } - val ignored = Router.getIgnoredChannelDesc(publicChannels, ignoreNodes = Set(c, j, randomKey.publicKey)) assert(ignored.toSet.contains(ChannelDesc(ShortChannelId(2L), b, c))) @@ -574,9 +551,7 @@ class RouteCalculationSpec extends FunSuite { } test("limit routes to 20 hops") { - val nodes = (for (_ <- 0 until 22) yield randomKey.publicKey).toList - val updates = nodes .zip(nodes.drop(1)) // (0, 1) :: (1, 2) :: ... .zipWithIndex // ((0, 1), 0) :: ((1, 2), 1) :: ... @@ -592,7 +567,6 @@ class RouteCalculationSpec extends FunSuite { } test("ignore cheaper route when it has more than 20 hops") { - val nodes = (for (_ <- 0 until 50) yield randomKey.publicKey).toList val updates = nodes @@ -610,7 +584,6 @@ class RouteCalculationSpec extends FunSuite { } test("ignore cheaper route when it has more than the requested CLTV") { - val f = randomKey.publicKey val g = makeGraph(List( @@ -627,7 +600,6 @@ class RouteCalculationSpec extends FunSuite { } test("ignore cheaper route when it grows longer than the requested size") { - val f = randomKey.publicKey val g = makeGraph(List( @@ -644,7 +616,6 @@ class RouteCalculationSpec extends FunSuite { } test("ignore loops") { - val updates = List( makeUpdate(1L, a, b, 10 msat, 10), makeUpdate(2L, b, c, 10 msat, 10), @@ -660,7 +631,6 @@ class RouteCalculationSpec extends FunSuite { } 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 updates = List( makeUpdate(1L, a, b, 10 msat, 10), // a -> b @@ -678,21 +648,20 @@ class RouteCalculationSpec extends FunSuite { assert(route1.map(hops2Ids) === Success(1 :: 3 :: 5 :: Nil)) } + // @formatter:off /** - * * +---+ +---+ +---+ * | A +-----+ | B +----------> | C | * +-+-+ | +-+-+ +-+-+ - * ^ | ^ | - * | | | | - * | v----> + | | + * ^ | ^ | + * | | | | + * | v----> + | | * +-+-+ <-+-+ +-+-+ * | D +----------> | E +----------> | F | * +---+ +---+ +---+ - * */ + // @formatter:on test("find the k-shortest paths in a graph, k=4") { - val (a, b, c, d, e, f) = ( PublicKey(hex"02999fa724ec3c244e4da52b4a91ad421dc96c9a810587849cd4b2469313519c73"), //a PublicKey(hex"03f1cb1af20fe9ccda3ea128e27d7c39ee27375c8480f11a87c17197e97541ca6a"), //b @@ -702,7 +671,6 @@ class RouteCalculationSpec extends FunSuite { PublicKey(hex"03fc5b91ce2d857f146fd9b986363374ffe04dc143d8bcd6d7664c8873c463cdfc") //f ) - val edges = Seq( makeUpdate(1L, d, a, 1 msat, 0), makeUpdate(2L, d, e, 1 msat, 0), @@ -734,7 +702,6 @@ class RouteCalculationSpec extends FunSuite { PublicKey(hex"03fc5b91ce2d857f146fd9b986363374ffe04dc143d8bcd6d7664c8873c463cdfc") //h ) - val edges = Seq( makeUpdate(10L, c, e, 2 msat, 0), makeUpdate(20L, c, d, 3 msat, 0), @@ -760,7 +727,6 @@ class RouteCalculationSpec extends FunSuite { } 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 @@ -791,7 +757,6 @@ class RouteCalculationSpec extends FunSuite { } test("select a random route below the requested fee") { - val strictFeeParams = DEFAULT_ROUTE_PARAMS.copy(maxFeeBase = 7 msat, maxFeePct = 0) // A -> B -> C -> D has total cost of 10000005 @@ -808,7 +773,7 @@ class RouteCalculationSpec extends FunSuite { ).toMap) (for {_ <- 0 to 10} yield Router.findRoute(g, a, d, DEFAULT_AMOUNT_MSAT, numRoutes = 3, routeParams = strictFeeParams)).map { - case Failure(thr) => assert(false, thr) + case Failure(thr) => fail(thr) case Success(someRoute) => val routeCost = Graph.pathWeight(hops2Edges(someRoute), DEFAULT_AMOUNT_MSAT, isPartial = false, 0, None).cost - DEFAULT_AMOUNT_MSAT @@ -819,7 +784,6 @@ class RouteCalculationSpec extends FunSuite { } test("Use weight ratios to when computing the edge weight") { - val largeCapacity = 8000000000L msat // A -> B -> C -> D is 'fee optimized', lower fees route (totFees = 2, totCltv = 4000) @@ -858,7 +822,6 @@ class RouteCalculationSpec extends FunSuite { } test("prefer going through an older channel if fees and CLTV are the same") { - val currentBlockHeight = 554000 val g = makeGraph(List( @@ -882,7 +845,6 @@ class RouteCalculationSpec extends FunSuite { } test("prefer a route with a smaller total CLTV if fees and score are the same") { - val g = makeGraph(List( makeUpdateShort(ShortChannelId(s"0x0x1"), a, b, feeBase = 1 msat, 0, minHtlc = 0 msat, maxHtlc = None, cltvDelta = CltvExpiryDelta(12)), makeUpdateShort(ShortChannelId(s"0x0x4"), a, e, feeBase = 1 msat, 0, minHtlc = 0 msat, maxHtlc = None, cltvDelta = CltvExpiryDelta(12)), @@ -892,7 +854,6 @@ class RouteCalculationSpec extends FunSuite { makeUpdateShort(ShortChannelId(s"0x0x6"), f, d, feeBase = 1 msat, 0, minHtlc = 0 msat, maxHtlc = None, cltvDelta = CltvExpiryDelta(12)) ).toMap) - val Success(routeScoreOptimized) = Router.findRoute(g, a, d, DEFAULT_AMOUNT_MSAT, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(ratios = Some(WeightRatios( ageFactor = 0.33, cltvDeltaFactor = 0.33, @@ -902,9 +863,7 @@ class RouteCalculationSpec extends FunSuite { assert(hops2Nodes(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 = makeGraph(List( @@ -926,7 +885,6 @@ class RouteCalculationSpec extends FunSuite { } test("cost function is monotonic") { - // This test have a channel (542280x2156x0) that according to heuristics is very convenient but actually useless to reach the target, // then if the cost function is not monotonic the path-finding breaks because the result path contains a loop. val updates = SortedMap( diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/router/RouterSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/router/RouterSpec.scala index 52e7226313..f3785dd226 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/router/RouterSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/router/RouterSpec.scala @@ -236,6 +236,23 @@ class RouterSpec extends BaseRouterSpec { assert(state.channels.flatMap(c => c.update_1_opt.toSeq ++ c.update_2_opt.toSeq).size == 8) } + test("send network statistics") { fixture => + import fixture._ + val sender = TestProbe() + sender.send(router, GetNetworkStats) + assert(sender.expectMsgType[Option[NetworkStats]] === None) + + // Network statistics should be computed after initial sync + router ! SyncProgress(1.0) + sender.send(router, GetNetworkStats) + + val Some(stats) = sender.expectMsgType[Option[NetworkStats]] + assert(stats.channels === 4) + assert(stats.nodes === 6) + assert(stats.capacity.median === 1000000.sat) + assert(stats.cltvExpiryDelta.median === CltvExpiryDelta(6)) + } + test("given a pre-computed route add the proper channel updates") { fixture => import fixture._ @@ -267,7 +284,7 @@ class RouterSpec extends BaseRouterSpec { probe.send(router, TickPruneStaleChannels) val sender = TestProbe() sender.send(router, GetRoutingState) - val state = sender.expectMsgType[RoutingState] + sender.expectMsgType[RoutingState] val update1 = makeChannelUpdate(Block.RegtestGenesisBlock.hash, priv_a, c, channelId, CltvExpiryDelta(7), 0 msat, 766000 msat, 10, 500000000L msat, timestamp = Platform.currentTime.millisecond.toSeconds) @@ -277,4 +294,5 @@ class RouterSpec extends BaseRouterSpec { val query = transport.expectMsgType[QueryShortChannelIds] assert(query.shortChannelIds.array == List(channelId)) } + }