diff --git a/docs/release-notes/eclair-vnext.md b/docs/release-notes/eclair-vnext.md index 6f20ed9c28..292776b98e 100644 --- a/docs/release-notes/eclair-vnext.md +++ b/docs/release-notes/eclair-vnext.md @@ -11,6 +11,7 @@ - `audit` now accepts `--count` and `--skip` parameters to limit the number of retrieved items (#2474, #2487) - `sendtoroute` removes the `--trampolineNodes` argument and implicitly uses a single trampoline hop (#2480) - `payinvoice` always returns the payment result when used with `--blocking`, even when using MPP (#2525) +- `node` returns high-level information about a remote node (#2568) ### Miscellaneous improvements and bug fixes diff --git a/eclair-core/eclair-cli b/eclair-core/eclair-cli index c7970e251e..45658ff423 100755 --- a/eclair-core/eclair-cli +++ b/eclair-core/eclair-cli @@ -56,6 +56,7 @@ and COMMAND is one of the available commands: - findroute - findroutetonode - findroutebetweennodes + - node - nodes === Invoice === 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 9170ceb344..ed616538e7 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala @@ -102,6 +102,8 @@ trait Eclair { def peers()(implicit timeout: Timeout): Future[Iterable[PeerInfo]] + def node(nodeId: PublicKey)(implicit timeout: Timeout): Future[Option[Router.PublicNode]] + def nodes(nodeIds_opt: Option[Set[PublicKey]] = None)(implicit timeout: Timeout): Future[Iterable[NodeAnnouncement]] def receive(description: Either[String, ByteVector32], amount_opt: Option[MilliSatoshi], expire_opt: Option[Long], fallbackAddress_opt: Option[String], paymentPreimage_opt: Option[ByteVector32])(implicit timeout: Timeout): Future[Bolt11Invoice] @@ -223,6 +225,13 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging { peerinfos <- Future.sequence(peers.map(peer => (peer ? GetPeerInfo(None)).mapTo[PeerInfo])) } yield peerinfos + override def node(nodeId: PublicKey)(implicit timeout: Timeout): Future[Option[Router.PublicNode]] = { + (appKit.router ? Router.GetNode(nodeId)).mapTo[Router.GetNodeResponse].map { + case n: PublicNode => Some(n) + case _: UnknownNode => None + } + } + override def nodes(nodeIds_opt: Option[Set[PublicKey]])(implicit timeout: Timeout): Future[Iterable[NodeAnnouncement]] = { (appKit.router ? Router.GetNodes) .mapTo[Iterable[NodeAnnouncement]] 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 773533bc17..aa437c95a1 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 @@ -201,6 +201,18 @@ class Router(val nodeParams: NodeParams, watcher: typed.ActorRef[ZmqWatcher.Comm sender() ! d.excludedChannels stay() + case Event(GetNode(nodeId), d) => + d.nodes.get(nodeId) match { + case Some(announcement) => + // This only provides a lower bound on the number of channels this peer has: disabled channels will be filtered out. + val activeChannels = d.graphWithBalances.graph.getIncomingEdgesOf(nodeId) + val totalCapacity = activeChannels.map(_.capacity).sum + sender() ! PublicNode(announcement, activeChannels.size, totalCapacity) + case None => + sender() ! UnknownNode(nodeId) + } + stay() + case Event(GetNodes, d) => sender() ! d.nodes.values stay() @@ -639,12 +651,11 @@ object Router { /** 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, duration_opt: Option[FiniteDuration]) case class LiftChannelExclusion(desc: ChannelDesc) + case object GetExcludedChannels sealed trait ExcludedChannelStatus case object ExcludedForever extends ExcludedChannelStatus case class ExcludedUntil(liftExclusionAt: TimestampSecond, timer: Cancellable) extends ExcludedChannelStatus - - case object GetExcludedChannels // @formatter:on // @formatter:off @@ -658,6 +669,11 @@ object Router { case object GetChannels case object GetChannelsMap case object GetChannelUpdates + + case class GetNode(nodeId: PublicKey) + sealed trait GetNodeResponse + case class PublicNode(announcement: NodeAnnouncement, activeChannels: Int, totalCapacity: Satoshi) extends GetNodeResponse + case class UnknownNode(nodeId: PublicKey) extends GetNodeResponse // @formatter:on // @formatter:off diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala index dcb79fff9e..b4e6e690e6 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala @@ -150,6 +150,25 @@ class EclairImplSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with I assertThrows[IllegalArgumentException](Await.result(eclair.send(None, 123 msat, expiredInvoice), 50 millis)) } + test("return node details") { f => + import f._ + + val eclair = new EclairImpl(kit) + + val ann = NodeAnnouncement(randomBytes64(), Features.empty, TimestampSecond(42L), randomKey().publicKey, Color(42, 42, 42), "ACINQ", Nil) + val remoteNode = Router.PublicNode(ann, 7, 561_000 sat) + eclair.node(ann.nodeId).pipeTo(sender.ref) + assert(router.expectMsgType[Router.GetNode].nodeId == ann.nodeId) + router.reply(remoteNode) + sender.expectMsg(Some(remoteNode)) + + val unknownNode = Router.UnknownNode(randomKey().publicKey) + eclair.node(unknownNode.nodeId).pipeTo(sender.ref) + assert(router.expectMsgType[Router.GetNode].nodeId == unknownNode.nodeId) + router.reply(unknownNode) + sender.expectMsg(None) + } + test("return node announcements") { f => import f._ 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 95a0fe4707..d90504c9b0 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 @@ -48,6 +48,50 @@ import scala.concurrent.duration._ class RouterSpec extends BaseRouterSpec { + test("properly announce valid new nodes announcements and ignore invalid ones") { fixture => + import fixture._ + val eventListener = TestProbe() + system.eventStream.subscribe(eventListener.ref, classOf[NetworkEvent]) + system.eventStream.subscribe(eventListener.ref, classOf[Rebroadcast]) + val peerConnection = TestProbe() + + { + // continue to rebroadcast node updates with deprecated Torv2 addresses + val torv2Address = List(NodeAddress.fromParts("hsmithsxurybd7uh.onion", 9735).get) + val node_c_torv2 = makeNodeAnnouncement(priv_c, "node-C", Color(123, 100, -40), torv2Address, TestConstants.Bob.nodeParams.features.nodeAnnouncementFeatures(), timestamp = TimestampSecond.now() + 1) + peerConnection.send(router, PeerRoutingMessage(peerConnection.ref, remoteNodeId, node_c_torv2)) + peerConnection.expectMsg(TransportHandler.ReadAck(node_c_torv2)) + peerConnection.expectMsg(GossipDecision.Accepted(node_c_torv2)) + eventListener.expectMsg(NodeUpdated(node_c_torv2)) + router ! Router.TickBroadcast + val rebroadcast = eventListener.expectMsgType[Rebroadcast] + assert(rebroadcast.nodes.contains(node_c_torv2)) + } + { + // rebroadcast node updates with a single DNS hostname addresses + val hostname = List(NodeAddress.fromParts("acinq.co", 9735).get) + val node_c_hostname = makeNodeAnnouncement(priv_c, "node-C", Color(123, 100, -40), hostname, TestConstants.Bob.nodeParams.features.nodeAnnouncementFeatures(), timestamp = TimestampSecond.now() + 10) + peerConnection.send(router, PeerRoutingMessage(peerConnection.ref, remoteNodeId, node_c_hostname)) + peerConnection.expectMsg(TransportHandler.ReadAck(node_c_hostname)) + peerConnection.expectMsg(GossipDecision.Accepted(node_c_hostname)) + eventListener.expectMsg(NodeUpdated(node_c_hostname)) + router ! Router.TickBroadcast + val rebroadcast = eventListener.expectMsgType[Rebroadcast] + assert(rebroadcast.nodes.contains(node_c_hostname)) + } + { + // do NOT rebroadcast node updates with more than one DNS hostname addresses + val multiHostnames = List(NodeAddress.fromParts("acinq.co", 9735).get, NodeAddress.fromParts("acinq.fr", 9735).get) + val node_c_noForward = makeNodeAnnouncement(priv_c, "node-C", Color(123, 100, -40), multiHostnames, TestConstants.Bob.nodeParams.features.nodeAnnouncementFeatures(), timestamp = TimestampSecond.now() + 20) + peerConnection.send(router, PeerRoutingMessage(peerConnection.ref, remoteNodeId, node_c_noForward)) + peerConnection.expectMsg(TransportHandler.ReadAck(node_c_noForward)) + peerConnection.expectMsg(GossipDecision.Accepted(node_c_noForward)) + eventListener.expectMsg(NodeUpdated(node_c_noForward)) + router ! Router.TickBroadcast + eventListener.expectNoMessage(100 millis) + } + } + test("properly announce valid new channels and ignore invalid ones") { fixture => import fixture._ val eventListener = TestProbe() @@ -262,6 +306,17 @@ class RouterSpec extends BaseRouterSpec { watcher.expectNoMessage(100 millis) } + test("get nodes") { fixture => + import fixture._ + + val probe = TestProbe() + val unknownNodeId = randomKey().publicKey + probe.send(router, GetNode(unknownNodeId)) + probe.expectMsg(UnknownNode(unknownNodeId)) + probe.send(router, GetNode(b)) + probe.expectMsg(PublicNode(node_b, 2, publicChannelCapacity * 2)) + } + test("properly announce lost channels and nodes") { fixture => import fixture._ val eventListener = TestProbe() @@ -1040,49 +1095,4 @@ class RouterSpec extends BaseRouterSpec { } } - test("properly announce valid new nodes announcements and ignore invalid ones") { fixture => - import fixture._ - val eventListener = TestProbe() - system.eventStream.subscribe(eventListener.ref, classOf[NetworkEvent]) - system.eventStream.subscribe(eventListener.ref, classOf[Rebroadcast]) - val peerConnection = TestProbe() - - { - // continue to rebroadcast node updates with deprecated Torv2 addresses - val torv2Address = List(NodeAddress.fromParts("hsmithsxurybd7uh.onion", 9735).get) - val node_c_torv2 = makeNodeAnnouncement(priv_c, "node-C", Color(123, 100, -40), torv2Address, TestConstants.Bob.nodeParams.features.nodeAnnouncementFeatures(), timestamp = TimestampSecond.now() + 1) - peerConnection.send(router, PeerRoutingMessage(peerConnection.ref, remoteNodeId, node_c_torv2)) - peerConnection.expectMsg(TransportHandler.ReadAck(node_c_torv2)) - peerConnection.expectMsg(GossipDecision.Accepted(node_c_torv2)) - eventListener.expectMsg(NodeUpdated(node_c_torv2)) - router ! Router.TickBroadcast - val rebroadcast = eventListener.expectMsgType[Rebroadcast] - assert(rebroadcast.nodes.contains(node_c_torv2)) - } - - { - // rebroadcast node updates with a single DNS hostname addresses - val hostname = List(NodeAddress.fromParts("acinq.co", 9735).get) - val node_c_hostname = makeNodeAnnouncement(priv_c, "node-C", Color(123, 100, -40), hostname, TestConstants.Bob.nodeParams.features.nodeAnnouncementFeatures(), timestamp = TimestampSecond.now() + 10) - peerConnection.send(router, PeerRoutingMessage(peerConnection.ref, remoteNodeId, node_c_hostname)) - peerConnection.expectMsg(TransportHandler.ReadAck(node_c_hostname)) - peerConnection.expectMsg(GossipDecision.Accepted(node_c_hostname)) - eventListener.expectMsg(NodeUpdated(node_c_hostname)) - router ! Router.TickBroadcast - val rebroadcast = eventListener.expectMsgType[Rebroadcast] - assert(rebroadcast.nodes.contains(node_c_hostname)) - } - - { - // do NOT rebroadcast node updates with more than one DNS hostname addresses - val multiHostnames = List(NodeAddress.fromParts("acinq.co", 9735).get, NodeAddress.fromParts("acinq.fr", 9735).get) - val node_c_noForward = makeNodeAnnouncement(priv_c, "node-C", Color(123, 100, -40), multiHostnames, TestConstants.Bob.nodeParams.features.nodeAnnouncementFeatures(), timestamp = TimestampSecond.now() + 20) - peerConnection.send(router, PeerRoutingMessage(peerConnection.ref, remoteNodeId, node_c_noForward)) - peerConnection.expectMsg(TransportHandler.ReadAck(node_c_noForward)) - peerConnection.expectMsg(GossipDecision.Accepted(node_c_noForward)) - eventListener.expectMsg(NodeUpdated(node_c_noForward)) - router ! Router.TickBroadcast - eventListener.expectNoMessage(100 millis) - } - } } diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/PathFinding.scala b/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/PathFinding.scala index e8241d7372..8e685bc9c1 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/PathFinding.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/PathFinding.scala @@ -56,12 +56,18 @@ trait PathFinding { } } + val node: Route = postRequest("node") { implicit t => + formFields(nodeIdFormParam) { nodeId => + completeOrNotFound(eclairApi.node(nodeId)) + } + } + val nodes: Route = postRequest("nodes") { implicit t => formFields(nodeIdsFormParam.?) { nodeIds_opt => complete(eclairApi.nodes(nodeIds_opt.map(_.toSet))) } } - val pathFindingRoutes: Route = findRoute ~ findRouteToNode ~ findRouteBetweenNodes ~ nodes + val pathFindingRoutes: Route = findRoute ~ findRouteToNode ~ findRouteBetweenNodes ~ node ~ nodes }