Skip to content

Commit

Permalink
Allow including routing hints when creating Bolt 11 invoice (#2909)
Browse files Browse the repository at this point in the history
When nodes only have private channels, they must include routing hints
in their Bolt 11 invoices to be able to receive payments. We add a
parameter to the `createinvoice` RPC for this. Note that this may leak
the channel outpoint if `scid_alias` isn't used.

Fixes #2802
  • Loading branch information
t-bast authored Sep 23, 2024
1 parent 7b25c5a commit 885b45b
Show file tree
Hide file tree
Showing 4 changed files with 26 additions and 10 deletions.
1 change: 1 addition & 0 deletions docs/release-notes/eclair-vnext.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Eclair will not allow remote peers to open new obsolete channels that do not sup
### API changes

- `channelstats` now takes optional parameters `--count` and `--skip` to control pagination. By default, it will return first 10 entries. (#2890)
- `createinvoice` now takes an optional `--privateChannelIds` parameter that can be used to add routing hints through private channels. (#2909)

### Miscellaneous improvements and bug fixes

Expand Down
22 changes: 18 additions & 4 deletions eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ trait Eclair {

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]
def receive(description: Either[String, ByteVector32], amount_opt: Option[MilliSatoshi], expire_opt: Option[Long], fallbackAddress_opt: Option[String], paymentPreimage_opt: Option[ByteVector32], privateChannelIds_opt: Option[List[ByteVector32]])(implicit timeout: Timeout): Future[Bolt11Invoice]

def newAddress(): Future[String]

Expand Down Expand Up @@ -330,14 +330,28 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
}
}

override 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] = {
override def receive(description: Either[String, ByteVector32], amount_opt: Option[MilliSatoshi], expire_opt: Option[Long], fallbackAddress_opt: Option[String], paymentPreimage_opt: Option[ByteVector32], privateChannelIds_opt: Option[List[ByteVector32]])(implicit timeout: Timeout): Future[Bolt11Invoice] = {
fallbackAddress_opt.foreach { fa =>
// If it's not a valid bitcoin address we throw an exception.
addressToPublicKeyScript(appKit.nodeParams.chainHash, fa) match {
case Left(failure) => throw new IllegalArgumentException(failure.toString)
case Right(_) => ()
}
} // if it's not a bitcoin address throws an exception
appKit.paymentHandler.toTyped.ask(ref => ReceiveStandardPayment(ref, amount_opt, description, expire_opt, fallbackAddress_opt = fallbackAddress_opt, paymentPreimage_opt = paymentPreimage_opt))
}
for {
routingHints <- getInvoiceRoutingHints(privateChannelIds_opt)
invoice <- appKit.paymentHandler.toTyped.ask[Bolt11Invoice](ref => ReceiveStandardPayment(ref, amount_opt, description, expire_opt, routingHints, fallbackAddress_opt, paymentPreimage_opt))
} yield invoice
}

private def getInvoiceRoutingHints(privateChannelIds_opt: Option[List[ByteVector32]])(implicit timeout: Timeout): Future[List[List[Bolt11Invoice.ExtraHop]]] = {
privateChannelIds_opt match {
case Some(channelIds) =>
(appKit.router ? GetRouterData).mapTo[Router.Data].map {
d => channelIds.flatMap(cid => d.privateChannels.get(cid)).flatMap(_.toIncomingExtraHop).map(hop => hop :: Nil)
}
case None => Future.successful(Nil)
}
}

override def newAddress(): Future[String] = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -313,15 +313,15 @@ class EclairImplSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with I

val fallBackAddressRaw = "muhtvdmsnbQEPFuEmxcChX58fGvXaaUoVt"
val eclair = new EclairImpl(kit)
eclair.receive(Left("some desc"), Some(123 msat), Some(456), Some(fallBackAddressRaw), None)
eclair.receive(Left("some desc"), Some(123 msat), Some(456), Some(fallBackAddressRaw), None, None)
val receive = paymentHandler.expectMsgType[ReceiveStandardPayment]

assert(receive.amount_opt.contains(123 msat))
assert(receive.expirySeconds_opt.contains(456))
assert(receive.fallbackAddress_opt.contains(fallBackAddressRaw))

// try with wrong address format
assertThrows[IllegalArgumentException](eclair.receive(Left("some desc"), Some(123 msat), Some(456), Some("wassa wassa"), None))
assertThrows[IllegalArgumentException](eclair.receive(Left("some desc"), Some(123 msat), Some(456), Some("wassa wassa"), None, None))
}

test("passing a payment_preimage to /createinvoice should result in an invoice with payment_hash=H(payment_preimage)") { f =>
Expand All @@ -331,7 +331,7 @@ class EclairImplSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with I
val eclair = new EclairImpl(kitWithPaymentHandler)
val paymentPreimage = randomBytes32()

eclair.receive(Left("some desc"), None, None, None, Some(paymentPreimage)).pipeTo(sender.ref)
eclair.receive(Left("some desc"), None, None, None, Some(paymentPreimage), None).pipeTo(sender.ref)
assert(sender.expectMsgType[Invoice].paymentHash == Crypto.sha256(paymentPreimage))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package fr.acinq.eclair.api.handlers

import akka.http.scaladsl.server.Route
import fr.acinq.bitcoin.scalacompat.ByteVector32
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
import fr.acinq.eclair.api.Service
import fr.acinq.eclair.api.directives.EclairDirectives
import fr.acinq.eclair.api.serde.FormParamExtractors._
Expand All @@ -28,9 +29,9 @@ trait Invoice {
import fr.acinq.eclair.api.serde.JsonSupport.{formats, marshaller, serialization}

val createInvoice: Route = postRequest("createinvoice") { implicit t =>
formFields("description".as[String].?, "descriptionHash".as[ByteVector32].?, amountMsatFormParam.?, "expireIn".as[Long].?, "fallbackAddress".as[String].?, "paymentPreimage".as[ByteVector32](bytes32Unmarshaller).?) {
case (Some(desc), None, amountMsat, expire, fallBackAddress, paymentPreimage_opt) => complete(eclairApi.receive(Left(desc), amountMsat, expire, fallBackAddress, paymentPreimage_opt))
case (None, Some(desc), amountMsat, expire, fallBackAddress, paymentPreimage_opt) => complete(eclairApi.receive(Right(desc), amountMsat, expire, fallBackAddress, paymentPreimage_opt))
formFields("description".as[String].?, "descriptionHash".as[ByteVector32].?, amountMsatFormParam.?, "expireIn".as[Long].?, "fallbackAddress".as[String].?, "paymentPreimage".as[ByteVector32](bytes32Unmarshaller).?, "privateChannelIds".as[List[ByteVector32]](bytes32ListUnmarshaller).?) {
case (Some(desc), None, amountMsat, expire, fallBackAddress, paymentPreimage_opt, privateChannelIds_opt) => complete(eclairApi.receive(Left(desc), amountMsat, expire, fallBackAddress, paymentPreimage_opt, privateChannelIds_opt))
case (None, Some(desc), amountMsat, expire, fallBackAddress, paymentPreimage_opt, privateChannelIds_opt) => complete(eclairApi.receive(Right(desc), amountMsat, expire, fallBackAddress, paymentPreimage_opt, privateChannelIds_opt))
case _ => failWith(new RuntimeException("Either 'description' (string) or 'descriptionHash' (sha256 hash of description string) must be supplied"))
}
}
Expand Down

0 comments on commit 885b45b

Please sign in to comment.