Skip to content

Commit

Permalink
Add 'shortChannelId' output format for findroute* API calls (#1943)
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
rorp and pm47 committed Sep 13, 2021
1 parent a228bac commit 03ac320
Show file tree
Hide file tree
Showing 5 changed files with 164 additions and 14 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}

}

Original file line number Diff line number Diff line change
Expand Up @@ -19,36 +19,40 @@ 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'"
))
}
}

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)))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,19 @@

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
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

Expand Down Expand Up @@ -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)))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down

0 comments on commit 03ac320

Please sign in to comment.