From 03ac320f21cfa84fa0b1b6f1c8f450d5fbe6c9eb Mon Sep 17 00:00:00 2001 From: rorp Date: Mon, 13 Sep 2021 00:04:45 -0700 Subject: [PATCH] Add 'shortChannelId' output format for findroute* API calls (#1943) Add new --format parameter to the findRoute* API calls. This lets the caller decide whether they want to receive a list of nodeIds or shortChannelIds to identify the route. Co-authored-by: Pierre-Marie Padiou --- .../api/directives/ExtraDirectives.scala | 1 + .../eclair/api/directives/RouteFormat.scala | 53 +++++++++++ .../eclair/api/handlers/PathFinding.scala | 24 +++-- .../api/serde/FormParamExtractors.scala | 6 +- .../fr/acinq/eclair/api/ApiServiceSpec.scala | 94 ++++++++++++++++++- 5 files changed, 164 insertions(+), 14 deletions(-) create mode 100644 eclair-node/src/main/scala/fr/acinq/eclair/api/directives/RouteFormat.scala diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/api/directives/ExtraDirectives.scala b/eclair-node/src/main/scala/fr/acinq/eclair/api/directives/ExtraDirectives.scala index bea2befe6c..bab2342a01 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/api/directives/ExtraDirectives.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/api/directives/ExtraDirectives.scala @@ -46,6 +46,7 @@ trait ExtraDirectives extends Directives { val toFormParam: NameReceptacle[Long] = "to".as[Long] val amountMsatFormParam: NameReceptacle[MilliSatoshi] = "amountMsat".as[MilliSatoshi] val invoiceFormParam: NameReceptacle[PaymentRequest] = "invoice".as[PaymentRequest] + val routeFormat: NameUnmarshallerReceptacle[RouteFormat] = "format".as[RouteFormat](routeFormatUnmarshaller) // custom directive to fail with HTTP 404 (and JSON response) if the element was not found def completeOrNotFound[T](fut: Future[Option[T]])(implicit marshaller: ToResponseMarshaller[T]): Route = onComplete(fut) { diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/api/directives/RouteFormat.scala b/eclair-node/src/main/scala/fr/acinq/eclair/api/directives/RouteFormat.scala new file mode 100644 index 0000000000..60856683ca --- /dev/null +++ b/eclair-node/src/main/scala/fr/acinq/eclair/api/directives/RouteFormat.scala @@ -0,0 +1,53 @@ +/* + * Copyright 2021 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.api.directives + +import fr.acinq.eclair.router.Router.RouteResponse + +// @formatter:off +sealed trait RouteFormat +case object NodeIdRouteFormat extends RouteFormat +case object ShortChannelIdRouteFormat extends RouteFormat +// @formatter:on + +object RouteFormat { + + val NODE_ID = "nodeId" + val SHORT_CHANNEL_ID = "shortChannelId" + + def fromString(s: String): RouteFormat = s match { + case NODE_ID => NodeIdRouteFormat + case SHORT_CHANNEL_ID => ShortChannelIdRouteFormat + case _ => throw new IllegalArgumentException(s"invalid route format, possible values are ($NODE_ID, $SHORT_CHANNEL_ID)") + } + + def format(route: RouteResponse, format_opt: Option[RouteFormat]): Seq[String] = format(route, format_opt.getOrElse(NodeIdRouteFormat)) + + def format(route: RouteResponse, format: RouteFormat): Seq[String] = format match { + case NodeIdRouteFormat => + val nodeIds = route.routes.head.hops match { + case rest :+ last => rest.map(_.nodeId) :+ last.nodeId :+ last.nextNodeId + case Nil => Nil + } + nodeIds.toList.map(_.toString) + case ShortChannelIdRouteFormat => + val shortChannelIds = route.routes.head.hops.map(_.lastUpdate.shortChannelId) + shortChannelIds.map(_.toString) + } + +} + 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 314dd414d4..874bc00a6c 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 @@ -19,21 +19,25 @@ package fr.acinq.eclair.api.handlers import akka.http.scaladsl.server.{MalformedFormFieldRejection, Route} import fr.acinq.bitcoin.Crypto.PublicKey import fr.acinq.eclair.api.Service -import fr.acinq.eclair.api.directives.EclairDirectives +import fr.acinq.eclair.api.directives.{EclairDirectives, RouteFormat} import fr.acinq.eclair.api.serde.FormParamExtractors._ import fr.acinq.eclair.payment.PaymentRequest +import scala.concurrent.ExecutionContext + trait PathFinding { this: Service with EclairDirectives => import fr.acinq.eclair.api.serde.JsonSupport.{formats, marshaller, serialization} + private implicit def ec: ExecutionContext = actorSystem.dispatcher + val findRoute: Route = postRequest("findroute") { implicit t => - formFields(invoiceFormParam, amountMsatFormParam.?, "pathFindingExperimentName".?) { - case (invoice@PaymentRequest(_, Some(amount), _, nodeId, _, _), None, pathFindingExperimentName_opt) => - complete(eclairApi.findRoute(nodeId, amount, pathFindingExperimentName_opt, invoice.routingInfo)) - case (invoice, Some(overrideAmount), pathFindingExperimentName_opt) => - complete(eclairApi.findRoute(invoice.nodeId, overrideAmount, pathFindingExperimentName_opt, invoice.routingInfo)) + formFields(invoiceFormParam, amountMsatFormParam.?, "pathFindingExperimentName".?, routeFormat.?) { + case (invoice@PaymentRequest(_, Some(amount), _, nodeId, _, _), None, pathFindingExperimentName_opt, routeFormat) => + complete(eclairApi.findRoute(nodeId, amount, pathFindingExperimentName_opt, invoice.routingInfo).map(r => RouteFormat.format(r, routeFormat))) + case (invoice, Some(overrideAmount), pathFindingExperimentName_opt, routeFormat) => + complete(eclairApi.findRoute(invoice.nodeId, overrideAmount, pathFindingExperimentName_opt, invoice.routingInfo).map(r => RouteFormat.format(r, routeFormat))) case _ => reject(MalformedFormFieldRejection( "invoice", "The invoice must have an amount or you need to specify one using 'amountMsat'" )) @@ -41,14 +45,14 @@ trait PathFinding { } val findRouteToNode: Route = postRequest("findroutetonode") { implicit t => - formFields(nodeIdFormParam, amountMsatFormParam, "pathFindingExperimentName".?) { (nodeId, amount, pathFindingExperimentName_opt) => - complete(eclairApi.findRoute(nodeId, amount, pathFindingExperimentName_opt)) + formFields(nodeIdFormParam, amountMsatFormParam, "pathFindingExperimentName".?, routeFormat.?) { (nodeId, amount, pathFindingExperimentName_opt, routeFormat) => + complete(eclairApi.findRoute(nodeId, amount, pathFindingExperimentName_opt).map(r => RouteFormat.format(r, routeFormat))) } } val findRouteBetweenNodes: Route = postRequest("findroutebetweennodes") { implicit t => - formFields("sourceNodeId".as[PublicKey], "targetNodeId".as[PublicKey], amountMsatFormParam, "pathFindingExperimentName".?) { (sourceNodeId, targetNodeId, amount, pathFindingExperimentName_opt) => - complete(eclairApi.findRouteBetween(sourceNodeId, targetNodeId, amount, pathFindingExperimentName_opt)) + formFields("sourceNodeId".as[PublicKey], "targetNodeId".as[PublicKey], amountMsatFormParam, "pathFindingExperimentName".?, routeFormat.?) { (sourceNodeId, targetNodeId, amount, pathFindingExperimentName_opt, routeFormat) => + complete(eclairApi.findRouteBetween(sourceNodeId, targetNodeId, amount, pathFindingExperimentName_opt).map(r => RouteFormat.format(r, routeFormat))) } } diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/api/serde/FormParamExtractors.scala b/eclair-node/src/main/scala/fr/acinq/eclair/api/serde/FormParamExtractors.scala index b348a0f91d..e2f064fcd9 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/api/serde/FormParamExtractors.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/api/serde/FormParamExtractors.scala @@ -16,12 +16,11 @@ package fr.acinq.eclair.api.serde -import java.util.UUID - import akka.http.scaladsl.unmarshalling.Unmarshaller import akka.util.Timeout import fr.acinq.bitcoin.Crypto.PublicKey import fr.acinq.bitcoin.{ByteVector32, Satoshi} +import fr.acinq.eclair.api.directives.RouteFormat import fr.acinq.eclair.api.serde.JsonSupport._ import fr.acinq.eclair.blockchain.fee.FeeratePerByte import fr.acinq.eclair.io.NodeURI @@ -29,6 +28,7 @@ import fr.acinq.eclair.payment.PaymentRequest import fr.acinq.eclair.{MilliSatoshi, ShortChannelId} import scodec.bits.ByteVector +import java.util.UUID import scala.concurrent.duration._ import scala.util.Try @@ -64,6 +64,8 @@ object FormParamExtractors { implicit val base64DataUnmarshaller: Unmarshaller[String, ByteVector] = Unmarshaller.strict { str => ByteVector.fromValidBase64(str) } + implicit val routeFormatUnmarshaller: Unmarshaller[String, RouteFormat] = Unmarshaller.strict { str => RouteFormat.fromString(str) } + private def listUnmarshaller[T](unmarshal: String => T): Unmarshaller[String, List[T]] = Unmarshaller.strict { str => Try(serialization.read[List[String]](str).map(unmarshal)) .recoverWith(_ => Try(str.split(",").toList.map(unmarshal))) diff --git a/eclair-node/src/test/scala/fr/acinq/eclair/api/ApiServiceSpec.scala b/eclair-node/src/test/scala/fr/acinq/eclair/api/ApiServiceSpec.scala index 2391b3766e..9223f9e960 100644 --- a/eclair-node/src/test/scala/fr/acinq/eclair/api/ApiServiceSpec.scala +++ b/eclair-node/src/test/scala/fr/acinq/eclair/api/ApiServiceSpec.scala @@ -44,8 +44,9 @@ import fr.acinq.eclair.payment.relay.Relayer.UsableBalance import fr.acinq.eclair.payment.send.MultiPartPaymentLifecycle.PreimageReceived import fr.acinq.eclair.payment.send.PaymentInitiator.SendPaymentToRouteResponse import fr.acinq.eclair.router.Router.PredefinedNodeRoute -import fr.acinq.eclair.router.{NetworkStats, Stats} -import fr.acinq.eclair.wire.protocol.{Color, NodeAddress} +import fr.acinq.eclair.router.{NetworkStats, Router, Stats} +import fr.acinq.eclair.wire.protocol.{ChannelUpdate, Color, NodeAddress} +import org.json4s.JsonAST.{JArray, JString} import org.mockito.scalatest.IdiomaticMockito import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -951,6 +952,95 @@ class ApiServiceSpec extends AnyFunSuite with ScalatestRouteTest with IdiomaticM } } + test("'findroute' method response should support both a node ID and channel ID formats") { + val invoice = "lnbc12580n1pw2ywztpp554ganw404sh4yjkwnysgn3wjcxfcq7gtx53gxczkjr9nlpc3hzvqdq2wpskwctddyxqr4rqrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glc7z9rtvqqwngqqqqqqqlgqqqqqeqqjqrrt8smgjvfj7sg38dwtr9kc9gg3era9k3t2hvq3cup0jvsrtrxuplevqgfhd3rzvhulgcxj97yjuj8gdx8mllwj4wzjd8gdjhpz3lpqqvk2plh" + + + val mockChannelUpdate1 = ChannelUpdate( + signature = randomBytes64(), + chainHash = randomBytes32(), + shortChannelId = ShortChannelId(1, 2, 3), + timestamp = 0, + channelFlags = ChannelUpdate.ChannelFlags.DUMMY, + cltvExpiryDelta = CltvExpiryDelta(0), + htlcMinimumMsat = MilliSatoshi(1), + feeBaseMsat = MilliSatoshi(1), + feeProportionalMillionths = 1, + htlcMaximumMsat = None + ) + + val mockHop1 = + Router.ChannelHop(nodeId = randomKey().publicKey, nextNodeId = randomKey().publicKey, mockChannelUpdate1) + val mockHop2 = + Router.ChannelHop(nodeId = mockHop1.nextNodeId, nextNodeId = randomKey().publicKey, mockChannelUpdate1.copy(shortChannelId = ShortChannelId(1, 2, 4))) + val mockHop3 = + Router.ChannelHop(nodeId = mockHop2.nextNodeId, nextNodeId = randomKey().publicKey, mockChannelUpdate1.copy(shortChannelId = ShortChannelId(1, 2, 5))) + val mockHops = Seq(mockHop1, mockHop2, mockHop3) + + val eclair = mock[Eclair] + val mockService = new MockService(eclair) + eclair.findRoute(any, any, any, any)(any[Timeout]) returns Future.successful(Router.RouteResponse(Seq(Router.Route(456.msat, mockHops)))) + + // invalid format + Post("/findroute", FormData("format"-> "invalid-output-format", "invoice" -> invoice, "amountMsat" -> "456")) ~> + addCredentials(BasicHttpCredentials("", mockApi().password)) ~> + addHeader("Content-Type", "application/json") ~> + Route.seal(mockService.findRoute) ~> + check { + assert(handled) + assert(status == BadRequest) + eclair.findRoute(PublicKey.fromBin(ByteVector.fromValidHex("036ded9bb8175d0c9fd3fad145965cf5005ec599570f35c682e710dc6001ff605e")), 456.msat, any, any)(any[Timeout]).wasNever(called) + } + + // default format + Post("/findroute", FormData("invoice" -> invoice, "amountMsat" -> "456")) ~> + addCredentials(BasicHttpCredentials("", mockApi().password)) ~> + addHeader("Content-Type", "application/json") ~> + Route.seal(mockService.findRoute) ~> + check { + assert(handled) + assert(status == OK) + assert(entityAs[JArray](Json4sSupport.unmarshaller, ClassTag(classOf[ErrorResponse])) == JArray(List( + JString(mockHop1.nodeId.toString()), + JString(mockHop2.nodeId.toString()), + JString(mockHop3.nodeId.toString()), + JString(mockHop3.nextNodeId.toString()) + ))) + eclair.findRoute(PublicKey.fromBin(ByteVector.fromValidHex("036ded9bb8175d0c9fd3fad145965cf5005ec599570f35c682e710dc6001ff605e")), 456.msat, any, any)(any[Timeout]).wasCalled(once) + } + + Post("/findroute", FormData("format" -> "nodeId", "invoice" -> invoice, "amountMsat" -> "456")) ~> + addCredentials(BasicHttpCredentials("", mockApi().password)) ~> + addHeader("Content-Type", "application/json") ~> + Route.seal(mockService.findRoute) ~> + check { + assert(handled) + assert(status == OK) + assert(entityAs[JArray](Json4sSupport.unmarshaller, ClassTag(classOf[ErrorResponse])) == JArray(List( + JString(mockHop1.nodeId.toString()), + JString(mockHop2.nodeId.toString()), + JString(mockHop3.nodeId.toString()), + JString(mockHop3.nextNodeId.toString()) + ))) + eclair.findRoute(PublicKey.fromBin(ByteVector.fromValidHex("036ded9bb8175d0c9fd3fad145965cf5005ec599570f35c682e710dc6001ff605e")), 456.msat, any, any)(any[Timeout]).wasCalled(twice) + } + + Post("/findroute", FormData("format" -> "shortChannelId", "invoice" -> invoice, "amountMsat" -> "456")) ~> + addCredentials(BasicHttpCredentials("", mockApi().password)) ~> + addHeader("Content-Type", "application/json") ~> + Route.seal(mockService.findRoute) ~> + check { + assert(handled) + assert(status == OK) + assert(entityAs[JArray](Json4sSupport.unmarshaller, ClassTag(classOf[ErrorResponse])) == JArray(List( + JString(mockHop1.lastUpdate.shortChannelId.toString()), + JString(mockHop2.lastUpdate.shortChannelId.toString()), + JString(mockHop3.lastUpdate.shortChannelId.toString()) + ))) + eclair.findRoute(PublicKey.fromBin(ByteVector.fromValidHex("036ded9bb8175d0c9fd3fad145965cf5005ec599570f35c682e710dc6001ff605e")), 456.msat, any, any)(any[Timeout]).wasCalled(threeTimes) + } + } + test("'networkstats' response should return expected statistics") { val capStat = Stats(30 sat, 12 sat, 14 sat, 20 sat, 40 sat, 46 sat, 48 sat) val cltvStat = Stats(CltvExpiryDelta(32), CltvExpiryDelta(11), CltvExpiryDelta(13), CltvExpiryDelta(22), CltvExpiryDelta(42), CltvExpiryDelta(51), CltvExpiryDelta(53))