Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Build Bolt12 invoices with provided intermediary nodes #2499

Merged
merged 9 commits into from Nov 24, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -54,6 +54,7 @@ case class Bolt12Invoice(records: TlvStream[InvoiceTlv]) extends Invoice {
val chain: ByteVector32 = records.get[Chain].map(_.hash).getOrElse(Block.LivenetGenesisBlock.hash)
val offerId: Option[ByteVector32] = records.get[OfferId].map(_.offerId)
val blindedPaths: Seq[RouteBlinding.BlindedRoute] = records.get[Paths].get.paths
val blindedPathsInfo: Seq[PaymentInfo] = records.get[PaymentPathsInfo].get.paymentInfo
val issuer: Option[String] = records.get[Issuer].map(_.issuer)
val quantity: Option[Long] = records.get[Quantity].map(_.quantity)
val refundFor: Option[ByteVector32] = records.get[RefundFor].map(_.refundedPaymentHash)
Expand Down Expand Up @@ -101,6 +102,8 @@ case class Bolt12Invoice(records: TlvStream[InvoiceTlv]) extends Invoice {

}

case class PaymentBlindedRoute(route: Sphinx.RouteBlinding.BlindedRoute, paymentInfo: PaymentInfo)

object Bolt12Invoice {
val hrp = "lni"
val DEFAULT_EXPIRY_SECONDS: Long = 7200
Expand All @@ -122,7 +125,7 @@ object Bolt12Invoice {
nodeKey: PrivateKey,
minFinalCltvExpiryDelta: CltvExpiryDelta,
features: Features[InvoiceFeature],
paths: Seq[Sphinx.RouteBlinding.BlindedRoute]): Bolt12Invoice = {
paths: Seq[PaymentBlindedRoute]): Bolt12Invoice = {
require(request.amount.nonEmpty || offer.amount.nonEmpty)
val amount = request.amount.orElse(offer.amount.map(_ * request.quantity)).get
val tlvs: Seq[InvoiceTlv] = Seq(
Expand All @@ -131,8 +134,8 @@ object Bolt12Invoice {
Some(Amount(amount)),
Some(Description(offer.description)),
if (!features.isEmpty) Some(FeaturesTlv(features.unscoped())) else None,
Some(Paths(paths)),
Some(PaymentPathsInfo(Seq(PaymentInfo(0 msat, 0, CltvExpiryDelta(0), 0 msat, amount, Features.empty)))),
Some(Paths(paths.map(_.route))),
Some(PaymentPathsInfo(paths.map(_.paymentInfo))),
offer.issuer.map(Issuer),
Some(NodeId(nodeKey.publicKey)),
request.quantity_opt.map(Quantity),
Expand Down
Expand Up @@ -22,21 +22,27 @@ import akka.actor.typed.scaladsl.Behaviors
import akka.actor.typed.scaladsl.adapter.ClassicActorContextOps
import akka.actor.{ActorContext, ActorRef, PoisonPill, Status}
import akka.event.{DiagnosticLoggingAdapter, LoggingAdapter}
import akka.pattern.{ask, pipe}
import akka.util.Timeout
import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey}
import fr.acinq.bitcoin.scalacompat.{ByteVector32, Crypto}
import fr.acinq.eclair.Logs.LogCategory
import fr.acinq.eclair.channel.{CMD_FAIL_HTLC, CMD_FULFILL_HTLC, RES_SUCCESS}
import fr.acinq.eclair.crypto.Sphinx
import fr.acinq.eclair.db._
import fr.acinq.eclair.payment.Bolt11Invoice.ExtraHop
import fr.acinq.eclair.payment.Monitoring.{Metrics, Tags}
import fr.acinq.eclair.payment._
import fr.acinq.eclair.router.BlindedRouteCreation.{aggregatePaymentInfo, createBlindedRouteFromHops, createBlindedRouteWithoutHops}
import fr.acinq.eclair.router.Router
import fr.acinq.eclair.router.Router.{ChannelHop, ChannelRelayParams}
import fr.acinq.eclair.wire.protocol.OfferTypes.{InvoiceRequest, Offer}
import fr.acinq.eclair.wire.protocol.PaymentOnion.FinalPayload
import fr.acinq.eclair.wire.protocol._
import fr.acinq.eclair.{CltvExpiry, CltvExpiryDelta, FeatureSupport, Features, InvoiceFeature, Logs, MilliSatoshi, MilliSatoshiLong, NodeParams, ShortChannelId, TimestampMilli, randomBytes, randomBytes32, randomKey}
import fr.acinq.eclair.{CltvExpiryDelta, FeatureSupport, Features, InvoiceFeature, Logs, MilliSatoshi, NodeParams, ShortChannelId, TimestampMilli, randomBytes32}
import scodec.bits.HexStringSyntax

import scala.concurrent.duration.DurationInt
import scala.concurrent.{ExecutionContextExecutor, Future}
import scala.util.{Failure, Success, Try}

/**
Expand Down Expand Up @@ -244,19 +250,43 @@ object MultiPartHandler {
paymentPreimage_opt: Option[ByteVector32] = None,
paymentType: String = PaymentType.Standard) extends ReceivePayment

/**
* A dummy blinded hop that will be added at the end of a blinded route.
* The fees and expiry delta should match those of real channels, otherwise it will be obvious that dummy hops are used.
*/
case class DummyBlindedHop(feeBase: MilliSatoshi, feeProportionalMillionths: Long, cltvExpiryDelta: CltvExpiryDelta)

/**
* A route that will be blinded and included in a Bolt 12 invoice.
*
* @param nodes a valid route ending at our nodeId.
* @param maxFinalExpiryDelta maximum expiry delta that senders can use: the route expiry will be computed based on this value.
* @param dummyHops (optional) dummy hops to add to the blinded route.
*/
case class ReceivingRoute(nodes: Seq[PublicKey], maxFinalExpiryDelta: CltvExpiryDelta, dummyHops: Seq[DummyBlindedHop] = Nil)

/**
* Use this message to create a Bolt 12 invoice to receive a payment for a given offer.
*
* @param nodeKey the key that will be used to sign the invoice, which may be different from our public nodeId.
* @param offer the offer this invoice corresponds to.
* @param invoiceRequest the request this invoice responds to.
* @param routes routes that must be blinded and provided in the invoice.
* @param router router actor.
* @param paymentPreimage_opt payment preimage.
*/
case class ReceiveOfferPayment(nodeKey: PrivateKey,
offer: Offer,
invoiceRequest: InvoiceRequest,
routes: Seq[ReceivingRoute],
router: ActorRef,
paymentPreimage_opt: Option[ByteVector32] = None,
paymentType: String = PaymentType.Blinded) extends ReceivePayment
paymentType: String = PaymentType.Blinded) extends ReceivePayment {
require(routes.forall(_.nodes.nonEmpty), "each route must have at least one node")
require(offer.amount.nonEmpty || invoiceRequest.amount.nonEmpty, "an amount must be specified in the offer or in the invoice request")

val amount = invoiceRequest.amount.orElse(offer.amount.map(_ * invoiceRequest.quantity)).get
}

object CreateInvoiceActor {

Expand All @@ -269,16 +299,16 @@ object MultiPartHandler {
Behaviors.setup { context =>
Behaviors.receiveMessage {
case CreateInvoice(replyTo, receivePayment) =>
Try {
val paymentPreimage = receivePayment.paymentPreimage_opt.getOrElse(randomBytes32())
val paymentHash = Crypto.sha256(paymentPreimage)
val featuresTrampolineOpt = if (nodeParams.enableTrampolinePayment) {
nodeParams.features.invoiceFeatures().add(Features.TrampolinePaymentPrototype, FeatureSupport.Optional)
} else {
nodeParams.features.invoiceFeatures()
}
receivePayment match {
case r: ReceiveStandardPayment =>
val paymentPreimage = receivePayment.paymentPreimage_opt.getOrElse(randomBytes32())
val paymentHash = Crypto.sha256(paymentPreimage)
val featuresTrampolineOpt = if (nodeParams.enableTrampolinePayment) {
nodeParams.features.invoiceFeatures().add(Features.TrampolinePaymentPrototype, FeatureSupport.Optional)
} else {
nodeParams.features.invoiceFeatures()
}
receivePayment match {
case r: ReceiveStandardPayment =>
Try {
val expirySeconds = r.expirySeconds_opt.getOrElse(nodeParams.invoiceExpiry.toSeconds)
val paymentMetadata = hex"2a"
val invoice = Bolt11Invoice(
Expand All @@ -297,26 +327,45 @@ object MultiPartHandler {
context.log.debug("generated invoice={} from amount={}", invoice.toString, r.amount_opt)
nodeParams.db.payments.addIncomingPayment(invoice, paymentPreimage, r.paymentType)
invoice
case r: ReceiveOfferPayment =>
// TODO: get blinded paths from the router instead
val pathId = RouteBlindingEncryptedDataTlv.PathId(randomBytes32())
val dummyConstraints = RouteBlindingEncryptedDataTlv.PaymentConstraints(CltvExpiry(nodeParams.currentBlockHeight + 144), 1 msat)
val dummyRelay = RouteBlindingEncryptedDataTlv.PaymentRelay(CltvExpiryDelta(0), 0, 0 msat)
val dummyScid = RouteBlindingEncryptedDataTlv.OutgoingChannelId(ShortChannelId.toSelf)
val dummyPath = Seq(
(nodeParams.nodeId, RouteBlindingEncryptedDataCodecs.blindedRouteDataCodec.encode(TlvStream(dummyScid, dummyRelay, dummyConstraints)).require.bytes),
(nodeParams.nodeId, RouteBlindingEncryptedDataCodecs.blindedRouteDataCodec.encode(TlvStream(dummyConstraints, pathId)).require.bytes),
)
val blindedRoute = Sphinx.RouteBlinding.create(randomKey(), dummyPath.map(_._1), dummyPath.map(_._2))
} match {
case Success(invoice) => replyTo ! invoice
case Failure(exception) => replyTo ! Status.Failure(exception)
}
case r: ReceiveOfferPayment if r.routes.exists(!_.nodes.lastOption.contains(nodeParams.nodeId)) =>
replyTo ! Status.Failure(new IllegalArgumentException("receiving routes must end at our node"))
case r: ReceiveOfferPayment =>
implicit val ec: ExecutionContextExecutor = context.executionContext
val log = context.log
Future.sequence(r.routes.map(route => {
val pathId = randomBytes32()
val dummyHops = route.dummyHops.map(h => {
val edge = Invoice.BasicEdge(nodeParams.nodeId, nodeParams.nodeId, ShortChannelId.toSelf, h.feeBase, h.feeProportionalMillionths, h.cltvExpiryDelta)
ChannelHop(edge.shortChannelId, edge.sourceNodeId, edge.targetNodeId, ChannelRelayParams.FromHint(edge))
})
if (route.nodes.length == 1) {
val blindedRoute = if (dummyHops.isEmpty) {
createBlindedRouteWithoutHops(route.nodes.last, pathId, nodeParams.channelConf.htlcMinimum, route.maxFinalExpiryDelta.toCltvExpiry(nodeParams.currentBlockHeight))
} else {
createBlindedRouteFromHops(dummyHops, pathId, nodeParams.channelConf.htlcMinimum, route.maxFinalExpiryDelta.toCltvExpiry(nodeParams.currentBlockHeight))
}
val paymentInfo = aggregatePaymentInfo(r.amount, dummyHops)
Future.successful((blindedRoute, paymentInfo, pathId))
} else {
implicit val timeout: Timeout = 10.seconds
r.router.ask(Router.FinalizeRoute(r.amount, Router.PredefinedNodeRoute(route.nodes))).mapTo[Router.RouteResponse].map(routeResponse => {
val clearRoute = routeResponse.routes.head
val blindedRoute = createBlindedRouteFromHops(clearRoute.hops ++ dummyHops, pathId, nodeParams.channelConf.htlcMinimum, route.maxFinalExpiryDelta.toCltvExpiry(nodeParams.currentBlockHeight))
val paymentInfo = aggregatePaymentInfo(r.amount, clearRoute.hops ++ dummyHops)
(blindedRoute, paymentInfo, pathId)
})
}
})).map(paths => {
val invoiceFeatures = featuresTrampolineOpt.remove(Features.RouteBlinding).add(Features.RouteBlinding, FeatureSupport.Mandatory)
val invoice = Bolt12Invoice(r.offer, r.invoiceRequest, paymentPreimage, r.nodeKey, nodeParams.channelConf.minFinalExpiryDelta, invoiceFeatures, Seq(blindedRoute.route))
context.log.debug("generated invoice={} for offerId={}", invoice.toString, r.offer.offerId)
nodeParams.db.payments.addIncomingBlindedPayment(invoice, paymentPreimage, Map(blindedRoute.lastBlinding -> pathId.data), r.paymentType)
val invoice = Bolt12Invoice(r.offer, r.invoiceRequest, paymentPreimage, r.nodeKey, nodeParams.channelConf.minFinalExpiryDelta, invoiceFeatures, paths.map { case (blindedRoute, paymentInfo, _) => PaymentBlindedRoute(blindedRoute.route, paymentInfo) })
log.debug("generated invoice={} for offerId={}", invoice.toString, r.offer.offerId)
nodeParams.db.payments.addIncomingBlindedPayment(invoice, paymentPreimage, paths.map { case (blindedRoute, _, pathId) => blindedRoute.lastBlinding -> pathId.bytes }.toMap, r.paymentType)
invoice
}
} match {
case Success(invoice) => replyTo ! invoice
case Failure(exception) => replyTo ! Status.Failure(exception)
}).recover(exception => Status.Failure(exception)).pipeTo(replyTo)
}
Behaviors.stopped
}
Expand Down
@@ -0,0 +1,75 @@
/*
* Copyright 2022 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.scalacompat.Crypto.PublicKey
import fr.acinq.eclair.crypto.Sphinx
import fr.acinq.eclair.router.Router.ChannelHop
import fr.acinq.eclair.wire.protocol.OfferTypes.PaymentInfo
import fr.acinq.eclair.wire.protocol.{RouteBlindingEncryptedDataCodecs, RouteBlindingEncryptedDataTlv, TlvStream}
import fr.acinq.eclair.{CltvExpiry, CltvExpiryDelta, Features, MilliSatoshi, MilliSatoshiLong, randomKey}
import scodec.bits.ByteVector

object BlindedRouteCreation {

/** Compute aggregated fees and expiry for a given route. */
def aggregatePaymentInfo(amount: MilliSatoshi, hops: Seq[ChannelHop]): PaymentInfo = {
val zeroPaymentInfo = PaymentInfo(0 msat, 0, CltvExpiryDelta(0), 0 msat, amount, Features.empty)
hops.foldRight(zeroPaymentInfo) {
case (channel, payInfo) =>
val newFeeBase = MilliSatoshi((channel.params.relayFees.feeBase.toLong * 1_000_000 + payInfo.feeBase.toLong * (1_000_000 + channel.params.relayFees.feeProportionalMillionths) + 1_000_000 - 1) / 1_000_000)
val newFeeProp = ((payInfo.feeProportionalMillionths + channel.params.relayFees.feeProportionalMillionths) * 1_000_000 + payInfo.feeProportionalMillionths * channel.params.relayFees.feeProportionalMillionths + 1_000_000 - 1) / 1_000_000
// Most nodes on the network set `htlc_maximum_msat` to the channel capacity. We cannot expect the route to be
// able to relay that amount, so we remove 10% as a safety margin.
val channelMaxHtlc = channel.params.htlcMaximum_opt.map(_ * 0.9).getOrElse(amount)
PaymentInfo(newFeeBase, newFeeProp, payInfo.cltvExpiryDelta + channel.cltvExpiryDelta, payInfo.minHtlc.max(channel.params.htlcMinimum), payInfo.maxHtlc.min(channelMaxHtlc), payInfo.allowedFeatures)
}
}

/** Create a blinded route from a non-empty list of channel hops. */
def createBlindedRouteFromHops(hops: Seq[Router.ChannelHop], pathId: ByteVector, minAmount: MilliSatoshi, routeFinalExpiry: CltvExpiry): Sphinx.RouteBlinding.BlindedRouteDetails = {
require(hops.nonEmpty, "route must contain at least one hop")
// We use the same constraints for all nodes so they can't use it to guess their position.
val routeExpiry = hops.foldLeft(routeFinalExpiry) { case (expiry, hop) => expiry + hop.cltvExpiryDelta }
val routeMinAmount = hops.foldLeft(minAmount) { case (amount, hop) => amount.max(hop.params.htlcMinimum) }
val finalPayload = RouteBlindingEncryptedDataCodecs.blindedRouteDataCodec.encode(TlvStream(
RouteBlindingEncryptedDataTlv.PaymentConstraints(routeExpiry, routeMinAmount),
RouteBlindingEncryptedDataTlv.PathId(pathId),
)).require.bytes
val payloads = hops.foldRight(Seq(finalPayload)) {
case (channel, payloads) =>
val payload = RouteBlindingEncryptedDataCodecs.blindedRouteDataCodec.encode(TlvStream(
RouteBlindingEncryptedDataTlv.OutgoingChannelId(channel.shortChannelId),
RouteBlindingEncryptedDataTlv.PaymentRelay(channel.cltvExpiryDelta, channel.params.relayFees.feeProportionalMillionths, channel.params.relayFees.feeBase),
RouteBlindingEncryptedDataTlv.PaymentConstraints(routeExpiry, routeMinAmount),
)).require.bytes
payload +: payloads
}
val nodeIds = hops.map(_.nodeId) :+ hops.last.nextNodeId
Sphinx.RouteBlinding.create(randomKey(), nodeIds, payloads)
}

/** Create a blinded route where the recipient is also the introduction point (which reveals the recipient's identity). */
def createBlindedRouteWithoutHops(nodeId: PublicKey, pathId: ByteVector, minAmount: MilliSatoshi, routeExpiry: CltvExpiry): Sphinx.RouteBlinding.BlindedRouteDetails = {
val finalPayload = RouteBlindingEncryptedDataCodecs.blindedRouteDataCodec.encode(TlvStream(
RouteBlindingEncryptedDataTlv.PaymentConstraints(routeExpiry, minAmount),
RouteBlindingEncryptedDataTlv.PathId(pathId),
)).require.bytes
Sphinx.RouteBlinding.create(randomKey(), Seq(nodeId), Seq(finalPayload))
}

}
Expand Up @@ -52,7 +52,7 @@ object RouteCalculation {
case edges if edges.nonEmpty && edges.forall(_.nonEmpty) =>
// select the largest edge (using balance when available, otherwise capacity).
val selectedEdges = edges.map(es => es.maxBy(e => e.balance_opt.getOrElse(e.capacity.toMilliSatoshi)))
val hops = selectedEdges.map(d => ChannelHop(d.desc.shortChannelId, d.desc.a, d.desc.b, d.params))
val hops = selectedEdges.map(e => ChannelHop(e.desc.shortChannelId, e.desc.a, e.desc.b, e.params))
ctx.sender() ! RouteResponse(Route(fr.amount, hops) :: Nil)
case _ =>
// some nodes in the supplied route aren't connected in our graph
Expand Down
Expand Up @@ -22,7 +22,7 @@ import fr.acinq.bitcoin.scalacompat.{Block, ByteVector32, ByteVector64, Crypto,
import fr.acinq.eclair.crypto.Sphinx.RouteBlinding.BlindedRoute
import fr.acinq.eclair.wire.protocol.OnionRoutingCodecs.{InvalidTlvPayload, MissingRequiredTlv}
import fr.acinq.eclair.wire.protocol.TlvCodecs.genericTlv
import fr.acinq.eclair.{CltvExpiryDelta, Feature, Features, InvoiceFeature, MilliSatoshi, TimestampSecond, UInt64}
import fr.acinq.eclair.{CltvExpiryDelta, Feature, Features, InvoiceFeature, MilliSatoshi, TimestampSecond, UInt64, nodeFee}
import fr.acinq.secp256k1.Secp256k1JvmKt
import scodec.Codec
import scodec.bits.ByteVector
Expand Down Expand Up @@ -65,7 +65,9 @@ object OfferTypes {
cltvExpiryDelta: CltvExpiryDelta,
minHtlc: MilliSatoshi,
maxHtlc: MilliSatoshi,
allowedFeatures: Features[Feature])
allowedFeatures: Features[Feature]) {
def fee(amount: MilliSatoshi): MilliSatoshi = nodeFee(feeBase, feeProportionalMillionths, amount)
}

case class PaymentPathsInfo(paymentInfo: Seq[PaymentInfo]) extends InvoiceTlv

Expand Down