diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala index 2617d7e1e7..9f09263c55 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala @@ -357,7 +357,7 @@ class Setup(val datadir: File, } dbEventHandler = system.actorOf(SimpleSupervisor.props(DbEventHandler.props(nodeParams), "db-event-handler", SupervisorStrategy.Resume)) register = system.actorOf(SimpleSupervisor.props(Register.props(), "register", SupervisorStrategy.Resume)) - offerManager = system.spawn(Behaviors.supervise(OfferManager(nodeParams, router, paymentTimeout = 1 minute)).onFailure(typed.SupervisorStrategy.resume), name = "offer-manager") + offerManager = system.spawn(Behaviors.supervise(OfferManager(nodeParams, paymentTimeout = 1 minute)).onFailure(typed.SupervisorStrategy.resume), name = "offer-manager") paymentHandler = system.actorOf(SimpleSupervisor.props(PaymentHandler.props(nodeParams, register, offerManager), "payment-handler", SupervisorStrategy.Resume)) triggerer = system.spawn(Behaviors.supervise(AsyncPaymentTriggerer()).onFailure(typed.SupervisorStrategy.resume), name = "async-payment-triggerer") peerReadyManager = system.spawn(Behaviors.supervise(PeerReadyManager()).onFailure(typed.SupervisorStrategy.restart), name = "peer-ready-manager") diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/package.scala b/eclair-core/src/main/scala/fr/acinq/eclair/package.scala index 92bccca7a3..810a5527f0 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/package.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/package.scala @@ -71,6 +71,17 @@ package object eclair { def nodeFee(relayFees: RelayFees, paymentAmount: MilliSatoshi): MilliSatoshi = nodeFee(relayFees.feeBase, relayFees.feeProportionalMillionths, paymentAmount) + /** + * @param baseFee fixed fee + * @param proportionalFee proportional fee (millionths) + * @param incomingAmount incoming payment amount + * @return the amount that a node should forward after paying itself the base and proportional fees + */ + def amountAfterFee(baseFee: MilliSatoshi, proportionalFee: Long, incomingAmount: MilliSatoshi): MilliSatoshi = + ((incomingAmount - baseFee).toLong * 1_000_000 + 1_000_000 + proportionalFee - 1).msat / (1_000_000 + proportionalFee) + + def amountAfterFee(relayFees: RelayFees, incomingAmount: MilliSatoshi): MilliSatoshi = amountAfterFee(relayFees.feeBase, relayFees.feeProportionalMillionths, incomingAmount) + implicit class MilliSatoshiLong(private val n: Long) extends AnyVal { def msat = MilliSatoshi(n) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentPacket.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentPacket.scala index bb696d1caa..083373f7e1 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentPacket.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentPacket.scala @@ -221,7 +221,6 @@ object IncomingPaymentPacket { case payload if payload.paymentConstraints_opt.exists(c => add.amountMsat < c.minAmount) => Left(InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket))) case payload if payload.paymentConstraints_opt.exists(c => c.maxCltvExpiry < add.cltvExpiry) => Left(InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket))) case payload if !Features.areCompatible(Features.empty, payload.allowedFeatures) => Left(InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket))) - case payload if add.amountMsat < payload.amount => Left(InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket))) case payload if add.cltvExpiry < payload.expiry => Left(InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket))) case payload => Right(FinalPacket(add, payload)) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/OfferManager.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/OfferManager.scala index a742b3d625..f15c1aa035 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/OfferManager.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/OfferManager.scala @@ -20,17 +20,21 @@ import akka.actor.typed.scaladsl.{ActorContext, Behaviors} import akka.actor.typed.{ActorRef, Behavior} import fr.acinq.bitcoin.scalacompat.Crypto.PrivateKey import fr.acinq.bitcoin.scalacompat.{ByteVector32, Crypto} +import fr.acinq.eclair.EncodedNodeId.ShortChannelIdDir import fr.acinq.eclair.crypto.Sphinx.RouteBlinding import fr.acinq.eclair.db.{IncomingBlindedPayment, IncomingPaymentStatus, PaymentType} import fr.acinq.eclair.message.{OnionMessages, Postman} -import fr.acinq.eclair.payment.MinimalBolt12Invoice import fr.acinq.eclair.payment.offer.OfferPaymentMetadata.MinimalInvoiceData import fr.acinq.eclair.payment.receive.MultiPartHandler import fr.acinq.eclair.payment.receive.MultiPartHandler.{CreateInvoiceActor, ReceivingRoute} +import fr.acinq.eclair.payment.relay.Relayer.RelayFees +import fr.acinq.eclair.payment.{Bolt12Invoice, MinimalBolt12Invoice} +import fr.acinq.eclair.router.BlindedRouteCreation.aggregatePaymentInfo +import fr.acinq.eclair.router.Router import fr.acinq.eclair.wire.protocol.OfferTypes.{InvoiceRequest, InvoiceTlv, Offer} import fr.acinq.eclair.wire.protocol.PaymentOnion.FinalPayload import fr.acinq.eclair.wire.protocol._ -import fr.acinq.eclair.{Logs, MilliSatoshi, NodeParams, TimestampMilli, TimestampSecond, randomBytes32} +import fr.acinq.eclair.{CltvExpiryDelta, Logs, MilliSatoshi, MilliSatoshiLong, NodeParams, TimestampMilli, TimestampSecond, nodeFee, randomBytes32} import scodec.bits.ByteVector import scala.concurrent.duration.FiniteDuration @@ -61,7 +65,7 @@ object OfferManager { case class RequestInvoice(messagePayload: MessageOnion.InvoiceRequestPayload, blindedKey: PrivateKey, postman: ActorRef[Postman.SendMessage]) extends Command - case class ReceivePayment(replyTo: ActorRef[MultiPartHandler.GetIncomingPaymentActor.Command], paymentHash: ByteVector32, payload: FinalPayload.Blinded) extends Command + case class ReceivePayment(replyTo: ActorRef[MultiPartHandler.GetIncomingPaymentActor.Command], paymentHash: ByteVector32, payload: FinalPayload.Blinded, amountReceived: MilliSatoshi) extends Command /** * Offer handlers must be implemented in separate plugins and respond to these two `HandlerCommand`. @@ -89,15 +93,15 @@ object OfferManager { private case class RegisteredOffer(offer: Offer, nodeKey: Option[PrivateKey], pathId_opt: Option[ByteVector32], handler: ActorRef[HandlerCommand]) - def apply(nodeParams: NodeParams, router: akka.actor.ActorRef, paymentTimeout: FiniteDuration): Behavior[Command] = { + def apply(nodeParams: NodeParams, paymentTimeout: FiniteDuration): Behavior[Command] = { Behaviors.setup { context => Behaviors.withMdc(Logs.mdc(category_opt = Some(Logs.LogCategory.PAYMENT))) { - new OfferManager(nodeParams, router, paymentTimeout, context).normal(Map.empty) + new OfferManager(nodeParams, paymentTimeout, context).normal(Map.empty) } } } - private class OfferManager(nodeParams: NodeParams, router: akka.actor.ActorRef, paymentTimeout: FiniteDuration, context: ActorContext[Command]) { + private class OfferManager(nodeParams: NodeParams, paymentTimeout: FiniteDuration, context: ActorContext[Command]) { def normal(registeredOffers: Map[ByteVector32, RegisteredOffer]): Behavior[Command] = { Behaviors.receiveMessage { case RegisterOffer(offer, nodeKey, pathId_opt, handler) => @@ -108,19 +112,19 @@ object OfferManager { registeredOffers.get(messagePayload.invoiceRequest.offer.offerId) match { case Some(registered) if registered.pathId_opt.map(_.bytes) == messagePayload.pathId_opt && messagePayload.invoiceRequest.isValid => context.log.debug("received valid invoice request for offerId={}", messagePayload.invoiceRequest.offer.offerId) - val child = context.spawnAnonymous(InvoiceRequestActor(nodeParams, messagePayload.invoiceRequest, registered.handler, registered.nodeKey.getOrElse(blindedKey), router, messagePayload.replyPath, postman)) + val child = context.spawnAnonymous(InvoiceRequestActor(nodeParams, messagePayload.invoiceRequest, registered.handler, registered.nodeKey.getOrElse(blindedKey), messagePayload.replyPath, postman)) child ! InvoiceRequestActor.RequestInvoice case _ => context.log.debug("offer {} is not registered or invoice request is invalid", messagePayload.invoiceRequest.offer.offerId) } Behaviors.same - case ReceivePayment(replyTo, paymentHash, payload) => + case ReceivePayment(replyTo, paymentHash, payload, amountReceived) => MinimalInvoiceData.decode(payload.pathId) match { case Some(signed) => registeredOffers.get(signed.offerId) match { case Some(RegisteredOffer(offer, _, _, handler)) => MinimalInvoiceData.verify(nodeParams.nodeId, signed) match { case Some(metadata) if Crypto.sha256(metadata.preimage) == paymentHash => - val child = context.spawnAnonymous(PaymentActor(nodeParams, replyTo, offer, metadata, paymentTimeout)) + val child = context.spawnAnonymous(PaymentActor(nodeParams, replyTo, offer, metadata, amountReceived, paymentTimeout)) handler ! HandlePayment(child, signed.offerId, metadata.pluginData_opt) case Some(_) => replyTo ! MultiPartHandler.GetIncomingPaymentActor.RejectPayment(s"preimage does not match payment hash for offer ${signed.offerId.toHex}") case None => replyTo ! MultiPartHandler.GetIncomingPaymentActor.RejectPayment(s"invalid signature for metadata for offer ${signed.offerId.toHex}") @@ -150,17 +154,38 @@ object OfferManager { * @param customTlvs custom TLVs to add to the invoice. */ case class ApproveRequest(amount: MilliSatoshi, - routes: Seq[ReceivingRoute], + routes: Seq[Route], pluginData_opt: Option[ByteVector] = None, additionalTlvs: Set[InvoiceTlv] = Set.empty, customTlvs: Set[GenericTlv] = Set.empty) extends Command + /** + * @param recipientPaysFees If true, fees for the blinded route will be hidden to the payer and paid by the recipient. + */ + case class Route(hops: Seq[Router.ChannelHop], recipientPaysFees: Boolean, maxFinalExpiryDelta: CltvExpiryDelta, shortChannelIdDir_opt: Option[ShortChannelIdDir] = None) { + def finalize(nodePriv: PrivateKey, preimage: ByteVector32, amount: MilliSatoshi, invoiceRequest: InvoiceRequest, minFinalExpiryDelta: CltvExpiryDelta, pluginData_opt: Option[ByteVector]): ReceivingRoute = { + val (paymentInfo, metadata) = if (recipientPaysFees) { + val realPaymentInfo = aggregatePaymentInfo(amount, hops, minFinalExpiryDelta) + val recipientFees = RelayFees(realPaymentInfo.feeBase, realPaymentInfo.feeProportionalMillionths) + val metadata = MinimalInvoiceData(preimage, invoiceRequest.payerId, TimestampSecond.now(), invoiceRequest.quantity, amount, recipientFees, pluginData_opt) + val paymentInfo = realPaymentInfo.copy(feeBase = 0 msat, feeProportionalMillionths = 0) + (paymentInfo, metadata) + } else { + val paymentInfo = aggregatePaymentInfo(amount, hops, minFinalExpiryDelta) + val metadata = MinimalInvoiceData(preimage, invoiceRequest.payerId, TimestampSecond.now(), invoiceRequest.quantity, amount, RelayFees.zero, pluginData_opt) + (paymentInfo, metadata) + } + val pathId = MinimalInvoiceData.encode(nodePriv, invoiceRequest.offer.offerId, metadata) + ReceivingRoute(hops, pathId, maxFinalExpiryDelta, paymentInfo, shortChannelIdDir_opt) + } + } + /** * Sent by the offer handler to reject the request. For instance because stock has been exhausted. */ case class RejectRequest(message: String) extends Command - private case class WrappedInvoiceResponse(response: CreateInvoiceActor.Bolt12InvoiceResponse) extends Command + private case class WrappedInvoiceResponse(invoice: Bolt12Invoice) extends Command private case class WrappedOnionMessageResponse(response: Postman.OnionMessageResponse) extends Command @@ -168,7 +193,6 @@ object OfferManager { invoiceRequest: InvoiceRequest, offerHandler: ActorRef[HandleInvoiceRequest], nodeKey: PrivateKey, - router: akka.actor.ActorRef, pathToSender: RouteBlinding.BlindedRoute, postman: ActorRef[Postman.SendMessage]): Behavior[Command] = { Behaviors.setup { context => @@ -176,7 +200,7 @@ object OfferManager { Behaviors.receiveMessagePartial { case RequestInvoice => offerHandler ! HandleInvoiceRequest(context.self, invoiceRequest) - new InvoiceRequestActor(nodeParams, invoiceRequest, nodeKey, router, pathToSender, postman, context).waitForHandler() + new InvoiceRequestActor(nodeParams, invoiceRequest, nodeKey, pathToSender, postman, context).waitForHandler() } } } @@ -185,7 +209,6 @@ object OfferManager { private class InvoiceRequestActor(nodeParams: NodeParams, invoiceRequest: InvoiceRequest, nodeKey: PrivateKey, - router: akka.actor.ActorRef, pathToSender: RouteBlinding.BlindedRoute, postman: ActorRef[Postman.SendMessage], context: ActorContext[Command]) { @@ -197,9 +220,8 @@ object OfferManager { waitForSent() case ApproveRequest(amount, routes, pluginData_opt, additionalTlvs, customTlvs) => val preimage = randomBytes32() - val metadata = MinimalInvoiceData(preimage, invoiceRequest.payerId, TimestampSecond.now(), invoiceRequest.quantity, amount, pluginData_opt) - val pathId = MinimalInvoiceData.encode(nodeParams.privateKey, invoiceRequest.offer.offerId, metadata) - val receivePayment = MultiPartHandler.ReceiveOfferPayment(context.messageAdapter[CreateInvoiceActor.Bolt12InvoiceResponse](WrappedInvoiceResponse), nodeKey, invoiceRequest, routes, router, preimage, pathId, additionalTlvs, customTlvs) + val receivingRoutes = routes.map(_.finalize(nodeParams.privateKey, preimage, amount, invoiceRequest, nodeParams.channelConf.minFinalExpiryDelta, pluginData_opt)) + val receivePayment = MultiPartHandler.ReceiveOfferPayment(context.messageAdapter[Bolt12Invoice](WrappedInvoiceResponse), nodeKey, invoiceRequest, receivingRoutes, preimage, additionalTlvs, customTlvs) val child = context.spawnAnonymous(CreateInvoiceActor(nodeParams)) child ! CreateInvoiceActor.CreateBolt12Invoice(receivePayment) waitForInvoice() @@ -208,16 +230,10 @@ object OfferManager { private def waitForInvoice(): Behavior[Command] = { Behaviors.receiveMessagePartial { - case WrappedInvoiceResponse(invoiceResponse) => - invoiceResponse match { - case CreateInvoiceActor.InvoiceCreated(invoice) => - context.log.debug("invoice created for offerId={} invoice={}", invoice.invoiceRequest.offer.offerId, invoice.toString) - postman ! Postman.SendMessage(OfferTypes.BlindedPath(pathToSender), OnionMessages.RoutingStrategy.FindRoute, TlvStream(OnionMessagePayloadTlv.Invoice(invoice.records)), expectsReply = false, context.messageAdapter[Postman.OnionMessageResponse](WrappedOnionMessageResponse)) - waitForSent() - case f: CreateInvoiceActor.InvoiceCreationFailed => - context.log.debug("invoice creation failed: {}", f.message) - Behaviors.stopped - } + case WrappedInvoiceResponse(invoice) => + context.log.debug("invoice created for offerId={} invoice={}", invoice.invoiceRequest.offer.offerId, invoice.toString) + postman ! Postman.SendMessage(OfferTypes.BlindedPath(pathToSender), OnionMessages.RoutingStrategy.FindRoute, TlvStream(OnionMessagePayloadTlv.Invoice(invoice.records)), expectsReply = false, context.messageAdapter[Postman.OnionMessageResponse](WrappedOnionMessageResponse)) + waitForSent() } } @@ -234,7 +250,8 @@ object OfferManager { sealed trait Command /** - * Sent by the offer handler. Causes the creation of a dummy invoice that matches as best as possible the actual invoice for this payment (since the actual invoice is not stored) and will be used in the payment handler. + * Sent by the offer handler. Causes the creation of a dummy invoice that matches as best as possible the actual + * invoice for this payment (since the actual invoice is not stored) and will be used in the payment handler. * * @param additionalTlvs additional TLVs to add to the dummy invoice. Should be the same as what was used for the actual invoice. * @param customTlvs custom TLVs to add to the dummy invoice. Should be the same as what was used for the actual invoice. @@ -246,14 +263,21 @@ object OfferManager { */ case class RejectPayment(reason: String) extends Command - def apply(nodeParams: NodeParams, replyTo: ActorRef[MultiPartHandler.GetIncomingPaymentActor.Command], offer: Offer, metadata: MinimalInvoiceData, timeout: FiniteDuration): Behavior[Command] = { + def apply(nodeParams: NodeParams, + replyTo: ActorRef[MultiPartHandler.GetIncomingPaymentActor.Command], + offer: Offer, + metadata: MinimalInvoiceData, + amount: MilliSatoshi, + timeout: FiniteDuration): Behavior[Command] = { Behaviors.setup { context => context.scheduleOnce(timeout, context.self, RejectPayment("plugin timeout")) Behaviors.receiveMessage { case AcceptPayment(additionalTlvs, customTlvs) => val minimalInvoice = MinimalBolt12Invoice(offer, nodeParams.chainHash, metadata.amount, metadata.quantity, Crypto.sha256(metadata.preimage), metadata.payerKey, metadata.createdAt, additionalTlvs, customTlvs) val incomingPayment = IncomingBlindedPayment(minimalInvoice, metadata.preimage, PaymentType.Blinded, TimestampMilli.now(), IncomingPaymentStatus.Pending) - replyTo ! MultiPartHandler.GetIncomingPaymentActor.ProcessPayment(incomingPayment) + // We may be deducing some of the blinded path fees from the received amount. + val maxRecipientPathFees = nodeFee(metadata.recipientPathFees, amount) + replyTo ! MultiPartHandler.GetIncomingPaymentActor.ProcessPayment(incomingPayment, maxRecipientPathFees) Behaviors.stopped case RejectPayment(reason) => replyTo ! MultiPartHandler.GetIncomingPaymentActor.RejectPayment(reason) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/OfferPaymentMetadata.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/OfferPaymentMetadata.scala index 5edb7c5fd5..e8ba0d5caa 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/OfferPaymentMetadata.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/offer/OfferPaymentMetadata.scala @@ -18,6 +18,7 @@ package fr.acinq.eclair.payment.offer import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, Crypto} +import fr.acinq.eclair.payment.relay.Relayer.RelayFees import fr.acinq.eclair.{MilliSatoshi, TimestampSecond} import scodec.bits.ByteVector @@ -33,22 +34,28 @@ import scodec.bits.ByteVector * We instead include payment metadata in the blinded route's path_id field which lets us generate a minimal invoice * once we receive the payment, that is similar to the one that was actually sent to the payer. It will not be exactly * the same (notably the blinding route will be missing) but it will contain what we need to fulfill the payment. + * + * Since the recipient is selecting the blinded route to themselves, it may be unfair to the payer if the blinded route + * requires a high routing fee. The recipient can instead opt into having some of those routing fees deducted from the + * amount they receive by setting [[MinimalInvoiceData.recipientPathFees]] to a non-zero value. */ object OfferPaymentMetadata { /** - * @param preimage preimage for that payment. - * @param payerKey payer key (from their invoice request). - * @param createdAt creation time of the invoice. - * @param quantity quantity of items requested. - * @param amount amount that must be paid. - * @param pluginData_opt optional data from the offer plugin. + * @param preimage preimage for that payment. + * @param payerKey payer key (from their invoice request). + * @param createdAt creation time of the invoice. + * @param quantity quantity of items requested. + * @param amount amount that must be paid. + * @param recipientPathFees the payment recipient may choose to pay part of the blinded path relay fees themselves. + * @param pluginData_opt optional data from the offer plugin. */ case class MinimalInvoiceData(preimage: ByteVector32, payerKey: PublicKey, createdAt: TimestampSecond, quantity: Long, amount: MilliSatoshi, + recipientPathFees: RelayFees, pluginData_opt: Option[ByteVector]) /** @@ -69,6 +76,7 @@ object OfferPaymentMetadata { ("createdAt" | timestampSecond) :: ("quantity" | uint64overflow) :: ("amount" | millisatoshi) :: + ("recipientPathFees" | (millisatoshi :: int64).as[RelayFees]) :: ("pluginData" | optional(bitsRemaining, bytes))).as[MinimalInvoiceData] private val signedDataCodec: Codec[SignedMinimalInvoiceData] = diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/receive/MultiPartHandler.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/receive/MultiPartHandler.scala index f749d80ba2..3d7722037b 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/receive/MultiPartHandler.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/receive/MultiPartHandler.scala @@ -18,13 +18,11 @@ package fr.acinq.eclair.payment.receive import akka.actor.Actor.Receive import akka.actor.typed.Behavior -import akka.actor.typed.scaladsl.AskPattern.Askable import akka.actor.typed.scaladsl.Behaviors -import akka.actor.typed.scaladsl.adapter.{ClassicActorContextOps, ClassicActorRefOps} +import akka.actor.typed.scaladsl.adapter.ClassicActorContextOps import akka.actor.{ActorContext, ActorRef, PoisonPill, typed} import akka.event.{DiagnosticLoggingAdapter, LoggingAdapter} -import akka.util.Timeout -import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} +import fr.acinq.bitcoin.scalacompat.Crypto.PrivateKey import fr.acinq.bitcoin.scalacompat.{ByteVector32, Crypto} import fr.acinq.eclair.EncodedNodeId.ShortChannelIdDir import fr.acinq.eclair.Logs.LogCategory @@ -35,19 +33,14 @@ import fr.acinq.eclair.payment.Bolt11Invoice.ExtraHop import fr.acinq.eclair.payment.Monitoring.{Metrics, Tags} import fr.acinq.eclair.payment._ import fr.acinq.eclair.payment.offer.OfferManager -import fr.acinq.eclair.router.BlindedRouteCreation.{aggregatePaymentInfo, createBlindedRouteFromHops, createBlindedRouteWithoutHops} +import fr.acinq.eclair.router.BlindedRouteCreation.createBlindedRouteFromHops import fr.acinq.eclair.router.Router -import fr.acinq.eclair.router.Router.{ChannelHop, HopRelayParams, PaymentRouteResponse} import fr.acinq.eclair.wire.protocol.OfferTypes.{InvoiceRequest, InvoiceTlv} import fr.acinq.eclair.wire.protocol.PaymentOnion.FinalPayload import fr.acinq.eclair.wire.protocol._ -import fr.acinq.eclair.{Bolt11Feature, CltvExpiryDelta, FeatureSupport, Features, Logs, MilliSatoshi, MilliSatoshiLong, NodeParams, ShortChannelId, TimestampMilli, randomBytes32} +import fr.acinq.eclair.{Bolt11Feature, CltvExpiryDelta, FeatureSupport, Features, Logs, MilliSatoshi, MilliSatoshiLong, NodeParams, TimestampMilli, randomBytes32} import scodec.bits.{ByteVector, HexStringSyntax} -import scala.concurrent.duration.DurationInt -import scala.concurrent.{ExecutionContextExecutor, Future} -import scala.util.{Failure, Success, Try} - /** * Simple payment handler that generates invoices and fulfills incoming htlcs. * @@ -132,15 +125,21 @@ class MultiPartHandler(nodeParams: NodeParams, register: ActorRef, db: IncomingP } } - case ProcessBlindedPacket(add, payload, payment) if doHandle(add.paymentHash) => + case ProcessBlindedPacket(add, payload, payment, maxRecipientPathFees) if doHandle(add.paymentHash) => Logs.withMdc(log)(Logs.mdc(paymentHash_opt = Some(add.paymentHash))) { - validateBlindedPayment(nodeParams, add, payload, payment) match { + validateBlindedPayment(nodeParams, add, payload, payment, maxRecipientPathFees) match { case Some(cmdFail) => Metrics.PaymentFailed.withTag(Tags.Direction, Tags.Directions.Received).withTag(Tags.Failure, Tags.FailureType(cmdFail)).increment() PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, add.channelId, cmdFail) case None => - log.debug("received payment for amount={} totalAmount={}", add.amountMsat, payload.totalAmount) + val recipientPathFees = payload.amount - add.amountMsat + log.debug("received payment for amount={} recipientPathFees={} totalAmount={}", add.amountMsat, recipientPathFees, payload.totalAmount) addHtlcPart(ctx, add, payload, payment) + if (recipientPathFees > 0.msat) { + // We've opted into deducing the blinded paths fees from the amount we receive for this payment. + // We add an artificial payment part for those fees, otherwise we will never reach the total amount. + pendingPayments.get(add.paymentHash).foreach(_._2 ! MultiPartPaymentFSM.RecipientBlindedPathFeePart(add.paymentHash, recipientPathFees, payload.totalAmount)) + } } } @@ -176,6 +175,7 @@ class MultiPartHandler(nodeParams: NodeParams, register: ActorRef, db: IncomingP failure match { case Some(failure) => p match { case p: MultiPartPaymentFSM.HtlcPart => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, p.htlc.channelId, CMD_FAIL_HTLC(p.htlc.id, FailureReason.LocalFailure(failure), commit = true)) + case _: MultiPartPaymentFSM.RecipientBlindedPathFeePart => () } case None => p match { // NB: this case shouldn't happen unless the sender violated the spec, so it's ok that we take a slightly more @@ -190,6 +190,7 @@ class MultiPartHandler(nodeParams: NodeParams, register: ActorRef, db: IncomingP PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, p.htlc.channelId, cmdFail) } }) + case _: MultiPartPaymentFSM.RecipientBlindedPathFeePart => () } } } @@ -197,8 +198,9 @@ class MultiPartHandler(nodeParams: NodeParams, register: ActorRef, db: IncomingP case DoFulfill(payment, MultiPartPaymentFSM.MultiPartPaymentSucceeded(paymentHash, parts)) if doHandle(paymentHash) => Logs.withMdc(log)(Logs.mdc(paymentHash_opt = Some(paymentHash))) { log.debug("fulfilling payment for amount={}", parts.map(_.amount).sum) - val received = PaymentReceived(paymentHash, parts.map { - case p: MultiPartPaymentFSM.HtlcPart => PaymentReceived.PartialPayment(p.amount, p.htlc.channelId) + val received = PaymentReceived(paymentHash, parts.flatMap { + case p: MultiPartPaymentFSM.HtlcPart => Some(PaymentReceived.PartialPayment(p.amount, p.htlc.channelId)) + case _: MultiPartPaymentFSM.RecipientBlindedPathFeePart => None }) val recordedInDb = payment match { // Incoming offer payments are not stored in the database until they have been paid. @@ -235,9 +237,9 @@ class MultiPartHandler(nodeParams: NodeParams, register: ActorRef, db: IncomingP object MultiPartHandler { // @formatter:off - case class ProcessPacket(add: UpdateAddHtlc, payload: FinalPayload.Standard, payment_opt: Option[IncomingStandardPayment]) - case class ProcessBlindedPacket(add: UpdateAddHtlc, payload: FinalPayload.Blinded, payment: IncomingBlindedPayment) - case class RejectPacket(add: UpdateAddHtlc, failure: FailureMessage) + private case class ProcessPacket(add: UpdateAddHtlc, payload: FinalPayload.Standard, payment_opt: Option[IncomingStandardPayment]) + private case class ProcessBlindedPacket(add: UpdateAddHtlc, payload: FinalPayload.Blinded, payment: IncomingBlindedPayment, maxRecipientPathFees: MilliSatoshi) + private case class RejectPacket(add: UpdateAddHtlc, failure: FailureMessage) case class DoFulfill(payment: IncomingPayment, success: MultiPartPaymentFSM.MultiPartPaymentSucceeded) case object GetPendingPayments @@ -265,20 +267,14 @@ 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 hops hops to reach our node, or the empty sequence if we do not want to hide our node id. + * @param pathId path id for this route. * @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, shortChannelIdDir_opt: Option[ShortChannelIdDir] = None) + case class ReceivingRoute(hops: Seq[Router.ChannelHop], pathId: ByteVector, maxFinalExpiryDelta: CltvExpiryDelta, paymentInfo: OfferTypes.PaymentInfo, shortChannelIdDir_opt: Option[ShortChannelIdDir] = None) /** * Use this message to create a Bolt 12 invoice to receive a payment for a given offer. @@ -287,20 +283,15 @@ object MultiPartHandler { * and may be different from our public nodeId. * @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 payment preimage. - * @param pathId path id that will be used for all payment paths. */ - case class ReceiveOfferPayment(replyTo: typed.ActorRef[CreateInvoiceActor.Bolt12InvoiceResponse], + case class ReceiveOfferPayment(replyTo: typed.ActorRef[Bolt12Invoice], nodeKey: PrivateKey, invoiceRequest: InvoiceRequest, routes: Seq[ReceivingRoute], - router: ActorRef, paymentPreimage: ByteVector32, - pathId: ByteVector, additionalTlvs: Set[InvoiceTlv] = Set.empty, customTlvs: Set[GenericTlv] = Set.empty) extends ReceivePayment { - require(routes.forall(_.nodes.nonEmpty), "each route must have at least one node") require(invoiceRequest.offer.amount.nonEmpty || invoiceRequest.amount.nonEmpty, "an amount must be specified in the offer or in the invoice request") val amount = invoiceRequest.amount.orElse(invoiceRequest.offer.amount.map(_ * invoiceRequest.quantity)).get @@ -312,13 +303,6 @@ object MultiPartHandler { sealed trait Command case class CreateBolt11Invoice(receivePayment: ReceiveStandardPayment) extends Command case class CreateBolt12Invoice(receivePayment: ReceiveOfferPayment) extends Command - private case class WrappedInvoiceResult(invoice: Try[Bolt12Invoice]) extends Command - - sealed trait Bolt12InvoiceResponse - case class InvoiceCreated(invoice: Bolt12Invoice) extends Bolt12InvoiceResponse - sealed trait InvoiceCreationFailed extends Bolt12InvoiceResponse { def message: String } - case object InvalidBlindedRouteRecipient extends InvoiceCreationFailed { override def message: String = "receiving routes must end at our node" } - case class BlindedRouteCreationFailed(message: String) extends InvoiceCreationFailed // @formatter:on def apply(nodeParams: NodeParams): Behavior[Command] = { @@ -351,56 +335,20 @@ object MultiPartHandler { nodeParams.db.payments.addIncomingPayment(invoice, paymentPreimage, r.paymentType) r.replyTo ! invoice Behaviors.stopped - case CreateBolt12Invoice(r) if r.routes.exists(!_.nodes.lastOption.contains(nodeParams.nodeId)) => - r.replyTo ! InvalidBlindedRouteRecipient - Behaviors.stopped case CreateBolt12Invoice(r) => - implicit val ec: ExecutionContextExecutor = context.executionContext - val log = context.log - context.pipeToSelf(Future.sequence(r.routes.map(route => { - val dummyHops = route.dummyHops.map(h => { - // We don't want to restrict HTLC size in dummy hops, so we use htlc_minimum_msat = 1 msat and htlc_maximum_msat = None. - val edge = Invoice.ExtraEdge(nodeParams.nodeId, nodeParams.nodeId, ShortChannelId.toSelf, h.feeBase, h.feeProportionalMillionths, h.cltvExpiryDelta, htlcMinimum = 1 msat, htlcMaximum_opt = None) - ChannelHop(edge.shortChannelId, edge.sourceNodeId, edge.targetNodeId, HopRelayParams.FromHint(edge)) - }) - if (route.nodes.length == 1) { - val blindedRoute = if (dummyHops.isEmpty) { - createBlindedRouteWithoutHops(route.nodes.last, r.pathId, nodeParams.channelConf.htlcMinimum, route.maxFinalExpiryDelta.toCltvExpiry(nodeParams.currentBlockHeight)) - } else { - createBlindedRouteFromHops(dummyHops, r.pathId, nodeParams.channelConf.htlcMinimum, route.maxFinalExpiryDelta.toCltvExpiry(nodeParams.currentBlockHeight)) - } - val contactInfo = route.shortChannelIdDir_opt match { - case Some(shortChannelIdDir) => BlindedRoute(shortChannelIdDir, blindedRoute.route.firstPathKey, blindedRoute.route.blindedHops) - case None => blindedRoute.route - } - val paymentInfo = aggregatePaymentInfo(r.amount, dummyHops, nodeParams.channelConf.minFinalExpiryDelta) - Future.successful(PaymentBlindedRoute(contactInfo, paymentInfo)) - } else { - r.router.toTyped.ask[PaymentRouteResponse](replyTo => Router.FinalizeRoute(replyTo, Router.PredefinedNodeRoute(r.amount, route.nodes)))(10.seconds, context.system.scheduler).mapTo[Router.RouteResponse].map(routeResponse => { - val clearRoute = routeResponse.routes.head - val blindedRoute = createBlindedRouteFromHops(clearRoute.hops ++ dummyHops, r.pathId, nodeParams.channelConf.htlcMinimum, route.maxFinalExpiryDelta.toCltvExpiry(nodeParams.currentBlockHeight)) - val contactInfo = route.shortChannelIdDir_opt match { - case Some(shortChannelIdDir) => BlindedRoute(shortChannelIdDir, blindedRoute.route.firstPathKey, blindedRoute.route.blindedHops) - case None => blindedRoute.route - } - val paymentInfo = aggregatePaymentInfo(r.amount, clearRoute.hops ++ dummyHops, nodeParams.channelConf.minFinalExpiryDelta) - PaymentBlindedRoute(contactInfo, paymentInfo) - }) + val paths = r.routes.map(route => { + val blindedRoute = createBlindedRouteFromHops(route.hops, nodeParams.nodeId, route.pathId, nodeParams.channelConf.htlcMinimum, route.maxFinalExpiryDelta.toCltvExpiry(nodeParams.currentBlockHeight)) + val contactInfo = route.shortChannelIdDir_opt match { + case Some(shortChannelIdDir) => BlindedRoute(shortChannelIdDir, blindedRoute.route.firstPathKey, blindedRoute.route.blindedHops) + case None => blindedRoute.route } - })).map(paths => { - val invoiceFeatures = nodeParams.features.bolt12Features() - val invoice = Bolt12Invoice(r.invoiceRequest, r.paymentPreimage, r.nodeKey, nodeParams.invoiceExpiry, invoiceFeatures, paths, r.additionalTlvs, r.customTlvs) - log.debug("generated invoice={} for offer={}", invoice.toString, r.invoiceRequest.offer.toString) - invoice - }))(WrappedInvoiceResult) - Behaviors.receiveMessagePartial { - case WrappedInvoiceResult(result) => - result match { - case Failure(f) => r.replyTo ! BlindedRouteCreationFailed(f.getMessage) - case Success(invoice) => r.replyTo ! InvoiceCreated(invoice) - } - Behaviors.stopped - } + PaymentBlindedRoute(contactInfo, route.paymentInfo) + }) + val invoiceFeatures = nodeParams.features.bolt12Features() + val invoice = Bolt12Invoice(r.invoiceRequest, r.paymentPreimage, r.nodeKey, nodeParams.invoiceExpiry, invoiceFeatures, paths, r.additionalTlvs, r.customTlvs) + context.log.debug("generated invoice={} for offer={}", invoice.toString, r.invoiceRequest.offer.toString) + r.replyTo ! invoice + Behaviors.stopped } } } @@ -411,7 +359,7 @@ object MultiPartHandler { // @formatter:off sealed trait Command case class GetIncomingPayment(replyTo: ActorRef) extends Command - case class ProcessPayment(payment: IncomingBlindedPayment) extends Command + case class ProcessPayment(payment: IncomingBlindedPayment, maxRecipientPathFees: MilliSatoshi) extends Command case class RejectPayment(reason: String) extends Command // @formatter:on @@ -431,7 +379,7 @@ object MultiPartHandler { } Behaviors.stopped case payload: FinalPayload.Blinded => - offerManager ! OfferManager.ReceivePayment(context.self, packet.add.paymentHash, payload) + offerManager ! OfferManager.ReceivePayment(context.self, packet.add.paymentHash, payload, packet.add.amountMsat) waitForPayment(context, nodeParams, replyTo, packet.add, payload) } } @@ -441,8 +389,8 @@ object MultiPartHandler { private def waitForPayment(context: typed.scaladsl.ActorContext[Command], nodeParams: NodeParams, replyTo: ActorRef, add: UpdateAddHtlc, payload: FinalPayload.Blinded): Behavior[Command] = { Behaviors.receiveMessagePartial { - case ProcessPayment(payment) => - replyTo ! ProcessBlindedPacket(add, payload, payment) + case ProcessPayment(payment, maxRecipientPathFees) => + replyTo ! ProcessBlindedPacket(add, payload, payment, maxRecipientPathFees) Behaviors.stopped case RejectPayment(reason) => context.log.info("rejecting blinded htlc #{} from channel {}: {}", add.id, add.channelId, reason) @@ -526,11 +474,14 @@ object MultiPartHandler { if (commonOk && secretOk) None else Some(cmdFail) } - private def validateBlindedPayment(nodeParams: NodeParams, add: UpdateAddHtlc, payload: FinalPayload.Blinded, record: IncomingBlindedPayment)(implicit log: LoggingAdapter): Option[CMD_FAIL_HTLC] = { + private def validateBlindedPayment(nodeParams: NodeParams, add: UpdateAddHtlc, payload: FinalPayload.Blinded, record: IncomingBlindedPayment, maxRecipientPathFees: MilliSatoshi)(implicit log: LoggingAdapter): Option[CMD_FAIL_HTLC] = { // We send the same error regardless of the failure to avoid probing attacks. val cmdFail = CMD_FAIL_HTLC(add.id, FailureReason.LocalFailure(IncorrectOrUnknownPaymentDetails(payload.totalAmount, nodeParams.currentBlockHeight)), commit = true) val commonOk = validateCommon(nodeParams, add, payload, record) - if (commonOk) None else Some(cmdFail) + // The payer isn't aware of the blinded path fees if we decided to hide them. The HTLC amount will thus be smaller + // than the onion amount, but should match when re-adding the blinded path fees. + val pathFeesOk = payload.amount - add.amountMsat <= maxRecipientPathFees + if (commonOk && pathFeesOk) None else Some(cmdFail) } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/receive/MultiPartPaymentFSM.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/receive/MultiPartPaymentFSM.scala index db28e8f4ab..6bccb65f41 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/receive/MultiPartPaymentFSM.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/receive/MultiPartPaymentFSM.scala @@ -138,6 +138,8 @@ object MultiPartPaymentFSM { override def paymentHash: ByteVector32 = htlc.paymentHash override def amount: MilliSatoshi = htlc.amountMsat } + /** The fee of a blinded route paid by the receiver (us). */ + case class RecipientBlindedPathFeePart(paymentHash: ByteVector32, amount: MilliSatoshi, totalAmount: MilliSatoshi) extends PaymentPart /** We successfully received all parts of the payment. */ case class MultiPartPaymentSucceeded(paymentHash: ByteVector32, parts: Queue[PaymentPart]) /** We aborted the payment because of an inconsistency in the payment set or because we didn't receive the total amount in reasonable time. */ diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/Relayer.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/Relayer.scala index 04813a94dc..0d69468b06 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/Relayer.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/Relayer.scala @@ -134,6 +134,10 @@ object Relayer extends Logging { require(feeProportionalMillionths >= 0.0, "feeProportionalMillionths must be nonnegative") } + object RelayFees { + val zero: RelayFees = RelayFees(MilliSatoshi(0), 0) + } + case class AsyncPaymentsParams(holdTimeoutBlocks: Int, cancelSafetyBeforeTimeout: CltvExpiryDelta) case class RelayParams(publicChannelFees: RelayFees, diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/router/BlindedRouteCreation.scala b/eclair-core/src/main/scala/fr/acinq/eclair/router/BlindedRouteCreation.scala index 37e31c7ff8..8e2e2696c8 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/router/BlindedRouteCreation.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/router/BlindedRouteCreation.scala @@ -40,9 +40,8 @@ object BlindedRouteCreation { } } - /** 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") + /** Create a blinded route from a list of channel hops. */ + def createBlindedRouteFromHops(hops: Seq[Router.ChannelHop], finalNodeId: PublicKey, pathId: ByteVector, minAmount: MilliSatoshi, routeFinalExpiry: CltvExpiry): Sphinx.RouteBlinding.BlindedRouteDetails = { // 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) } @@ -82,19 +81,10 @@ object BlindedRouteCreation { tlvs.copy(records = tlvs.records + RouteBlindingEncryptedDataTlv.Padding(ByteVector.fill(targetLength - payloadLength)(0))) }) val encodedPayloads = paddedPayloads.map(RouteBlindingEncryptedDataCodecs.blindedRouteDataCodec.encode(_).require.bytes) :+ finalPayload - val nodeIds = hops.map(_.nodeId) :+ hops.last.nextNodeId + val nodeIds = hops.map(_.nodeId) :+ finalNodeId Sphinx.RouteBlinding.create(randomKey(), nodeIds, encodedPayloads) } - /** 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)) - } - /** Create a blinded route where the recipient is a wallet node. */ def createBlindedRouteToWallet(hop: Router.ChannelHop, pathId: ByteVector, minAmount: MilliSatoshi, routeFinalExpiry: CltvExpiry): Sphinx.RouteBlinding.BlindedRouteDetails = { val routeExpiry = routeFinalExpiry + hop.cltvExpiryDelta 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 a849fb42c0..dcc9e55114 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 @@ -462,7 +462,7 @@ object Router { for { scid <- aliases.remoteAlias_opt update <- remoteUpdate_opt - } yield (Bolt11Invoice.ExtraHop(remoteNodeId, scid, update.feeBaseMsat, update.feeProportionalMillionths, update.cltvExpiryDelta)) + } yield Bolt11Invoice.ExtraHop(remoteNodeId, scid, update.feeBaseMsat, update.feeProportionalMillionths, update.cltvExpiryDelta) } } // @formatter:on @@ -533,6 +533,14 @@ object Router { // @formatter:on } + object ChannelHop { + /** Create a dummy channel hop, used for example when padding blinded routes to a fixed length. */ + def dummy(nodeId: PublicKey, feeBase: MilliSatoshi, feeProportionalMillionths: Long, cltvExpiryDelta: CltvExpiryDelta): ChannelHop = { + val dummyEdge = ExtraEdge(nodeId, nodeId, ShortChannelId.toSelf, feeBase, feeProportionalMillionths, cltvExpiryDelta, 1 msat, None) + ChannelHop(ShortChannelId.toSelf, nodeId, nodeId, HopRelayParams.FromHint(dummyEdge)) + } + } + sealed trait FinalHop extends Hop /** @@ -668,11 +676,13 @@ object Router { } } + // @formatter:off sealed trait PaymentRouteResponse case class RouteResponse(routes: Seq[Route]) extends PaymentRouteResponse { require(routes.nonEmpty, "routes cannot be empty") } case class PaymentRouteNotFound(error: Throwable) extends PaymentRouteResponse + // @formatter:on // @formatter:off /** A pre-defined route chosen outside of eclair (e.g. manually by a user to do some re-balancing). */ diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/RouteBlinding.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/RouteBlinding.scala index 0ee4626a1e..93e6c9ad61 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/RouteBlinding.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/RouteBlinding.scala @@ -21,7 +21,7 @@ import fr.acinq.eclair.crypto.Sphinx import fr.acinq.eclair.wire.protocol.CommonCodecs.{cltvExpiry, cltvExpiryDelta, featuresCodec} import fr.acinq.eclair.wire.protocol.OnionRoutingCodecs.{ForbiddenTlv, InvalidTlvPayload, MissingRequiredTlv} import fr.acinq.eclair.wire.protocol.TlvCodecs.{fixedLengthTlvField, tlvField, tmillisatoshi, tmillisatoshi32} -import fr.acinq.eclair.{CltvExpiry, CltvExpiryDelta, EncodedNodeId, Feature, Features, MilliSatoshi, MilliSatoshiLong, ShortChannelId, UInt64} +import fr.acinq.eclair.{CltvExpiry, CltvExpiryDelta, EncodedNodeId, Feature, Features, MilliSatoshi, ShortChannelId, UInt64, amountAfterFee} import scodec.bits.ByteVector import scala.util.{Failure, Success} @@ -106,8 +106,7 @@ object BlindedRouteData { val paymentConstraints: PaymentConstraints = records.get[RouteBlindingEncryptedDataTlv.PaymentConstraints].get val allowedFeatures: Features[Feature] = records.get[RouteBlindingEncryptedDataTlv.AllowedFeatures].map(_.features).getOrElse(Features.empty) - def amountToForward(incomingAmount: MilliSatoshi): MilliSatoshi = - ((incomingAmount - paymentRelay.feeBase).toLong * 1_000_000 + 1_000_000 + paymentRelay.feeProportionalMillionths - 1).msat / (1_000_000 + paymentRelay.feeProportionalMillionths) + def amountToForward(incomingAmount: MilliSatoshi): MilliSatoshi = amountAfterFee(paymentRelay.feeBase, paymentRelay.feeProportionalMillionths, incomingAmount) def outgoingCltv(incomingCltv: CltvExpiry): CltvExpiry = incomingCltv - paymentRelay.cltvExpiryDelta } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/PackageSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/PackageSpec.scala index 593ad580ed..8e7b1b5fb7 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/PackageSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/PackageSpec.scala @@ -20,6 +20,7 @@ import fr.acinq.bitcoin.BitcoinError.ChainHashMismatch import fr.acinq.bitcoin.scalacompat.Crypto.PrivateKey import fr.acinq.bitcoin.scalacompat.{Block, ByteVector32, Crypto, Script, TxHash, TxId, addressToPublicKeyScript} import fr.acinq.bitcoin.{Base58, Base58Check, Bech32} +import org.scalatest.Tag import org.scalatest.funsuite.AnyFunSuite import scodec.bits._ @@ -114,4 +115,15 @@ class PackageSpec extends AnyFunSuite { assert(ShortChannelId(Long.MaxValue) < ShortChannelId(Long.MaxValue + 1)) } + test("node fees", Tag("fuzzy")) { + val rng = new scala.util.Random() + for (_ <- 1 to 100) { + val amount = rng.nextLong(1_000_000_000_000L) msat + val baseFee = rng.nextLong(10_000) msat + val proportionalFee = rng.nextLong(5_000) + val amountWithFees = amount + nodeFee(baseFee, proportionalFee, amount) + assert(amountAfterFee(baseFee, proportionalFee, amountWithFees) == amount, s"amount=$amount baseFee=$baseFee proportionalFee=$proportionalFee") + } + } + } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/integration/PaymentIntegrationSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/integration/PaymentIntegrationSpec.scala index 73fd0f01f7..436a2d5e75 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/integration/PaymentIntegrationSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/integration/PaymentIntegrationSpec.scala @@ -37,13 +37,14 @@ import fr.acinq.eclair.db._ import fr.acinq.eclair.io.Peer.PeerRoutingMessage import fr.acinq.eclair.message.OnionMessages.{IntermediateNode, Recipient, buildRoute} import fr.acinq.eclair.payment._ +import fr.acinq.eclair.payment.offer.OfferManager import fr.acinq.eclair.payment.offer.OfferManager._ -import fr.acinq.eclair.payment.receive.MultiPartHandler.{DummyBlindedHop, ReceiveStandardPayment, ReceivingRoute} +import fr.acinq.eclair.payment.receive.MultiPartHandler.ReceiveStandardPayment import fr.acinq.eclair.payment.relay.Relayer import fr.acinq.eclair.payment.relay.Relayer.RelayFees import fr.acinq.eclair.payment.send.PaymentInitiator.{SendPaymentToNode, SendTrampolinePayment} import fr.acinq.eclair.router.Graph.PaymentWeightRatios -import fr.acinq.eclair.router.Router.{GossipDecision, PublicChannel} +import fr.acinq.eclair.router.Router.{ChannelHop, GossipDecision, PublicChannel} import fr.acinq.eclair.router.{Announcements, AnnouncementsBatchValidationSpec, Router} import fr.acinq.eclair.wire.protocol.OfferTypes.{Offer, OfferPaths} import fr.acinq.eclair.wire.protocol.{ChannelAnnouncement, ChannelUpdate, IncorrectOrUnknownPaymentDetails} @@ -624,11 +625,18 @@ class PaymentIntegrationSpec extends IntegrationSpec { val bob = new EclairImpl(nodes("B")) bob.payOfferBlocking(offer, amount, 1, maxAttempts_opt = Some(3))(30 seconds).pipeTo(sender.ref) + nodes("D").router ! Router.FinalizeRoute(sender.ref, Router.PredefinedNodeRoute(amount, Seq(nodes("G").nodeParams.nodeId, nodes("C").nodeParams.nodeId, nodes("D").nodeParams.nodeId))) + val route1 = sender.expectMsgType[Router.RouteResponse].routes.head + nodes("D").router ! Router.FinalizeRoute(sender.ref, Router.PredefinedNodeRoute(amount, Seq(nodes("B").nodeParams.nodeId, nodes("C").nodeParams.nodeId, nodes("D").nodeParams.nodeId))) + val route2 = sender.expectMsgType[Router.RouteResponse].routes.head + nodes("D").router ! Router.FinalizeRoute(sender.ref, Router.PredefinedNodeRoute(amount, Seq(nodes("E").nodeParams.nodeId, nodes("C").nodeParams.nodeId, nodes("D").nodeParams.nodeId))) + val route3 = sender.expectMsgType[Router.RouteResponse].routes.head + val handleInvoiceRequest = offerHandler.expectMessageType[HandleInvoiceRequest] val receivingRoutes = Seq( - ReceivingRoute(Seq(nodes("G").nodeParams.nodeId, nodes("C").nodeParams.nodeId, nodes("D").nodeParams.nodeId), CltvExpiryDelta(1000)), - ReceivingRoute(Seq(nodes("B").nodeParams.nodeId, nodes("C").nodeParams.nodeId, nodes("D").nodeParams.nodeId), CltvExpiryDelta(1000)), - ReceivingRoute(Seq(nodes("E").nodeParams.nodeId, nodes("C").nodeParams.nodeId, nodes("D").nodeParams.nodeId), CltvExpiryDelta(1000)), + OfferManager.InvoiceRequestActor.Route(route1.hops, recipientPaysFees = false, CltvExpiryDelta(1000)), + OfferManager.InvoiceRequestActor.Route(route2.hops, recipientPaysFees = false, CltvExpiryDelta(1000)), + OfferManager.InvoiceRequestActor.Route(route3.hops, recipientPaysFees = false, CltvExpiryDelta(1000)), ) handleInvoiceRequest.replyTo ! InvoiceRequestActor.ApproveRequest(amount, receivingRoutes, pluginData_opt = Some(hex"abcd")) @@ -660,8 +668,8 @@ class PaymentIntegrationSpec extends IntegrationSpec { val handleInvoiceRequest = offerHandler.expectMessageType[HandleInvoiceRequest] // C uses a 0-hop blinded route and signs the invoice with its public nodeId. val receivingRoutes = Seq( - ReceivingRoute(Seq(nodes("C").nodeParams.nodeId), CltvExpiryDelta(1000)), - ReceivingRoute(Seq(nodes("C").nodeParams.nodeId), CltvExpiryDelta(1000)), + OfferManager.InvoiceRequestActor.Route(Nil, recipientPaysFees = false, CltvExpiryDelta(1000)), + OfferManager.InvoiceRequestActor.Route(Nil, recipientPaysFees = false, CltvExpiryDelta(1000)), ) handleInvoiceRequest.replyTo ! InvoiceRequestActor.ApproveRequest(amount, receivingRoutes, pluginData_opt = Some(hex"0123")) @@ -695,7 +703,7 @@ class PaymentIntegrationSpec extends IntegrationSpec { val handleInvoiceRequest = offerHandler.expectMessageType[HandleInvoiceRequest] val receivingRoutes = Seq( - ReceivingRoute(Seq(nodes("A").nodeParams.nodeId), CltvExpiryDelta(1000), Seq(DummyBlindedHop(100 msat, 100, CltvExpiryDelta(48)), DummyBlindedHop(150 msat, 50, CltvExpiryDelta(36)))) + OfferManager.InvoiceRequestActor.Route(Seq(ChannelHop.dummy(nodes("A").nodeParams.nodeId, 100 msat, 100, CltvExpiryDelta(48)), ChannelHop.dummy(nodes("A").nodeParams.nodeId, 150 msat, 50, CltvExpiryDelta(36))), recipientPaysFees = false, CltvExpiryDelta(1000)) ) handleInvoiceRequest.replyTo ! InvoiceRequestActor.ApproveRequest(amount, receivingRoutes) @@ -727,9 +735,12 @@ class PaymentIntegrationSpec extends IntegrationSpec { val bob = new EclairImpl(nodes("B")) bob.payOfferBlocking(offer, amount, 1, maxAttempts_opt = Some(3))(30 seconds).pipeTo(sender.ref) + nodes("C").router ! Router.FinalizeRoute(sender.ref, Router.PredefinedNodeRoute(amount, Seq(nodes("B").nodeParams.nodeId, nodes("C").nodeParams.nodeId))) + val route = sender.expectMsgType[Router.RouteResponse].routes.head + val handleInvoiceRequest = offerHandler.expectMessageType[HandleInvoiceRequest] val receivingRoutes = Seq( - ReceivingRoute(Seq(nodes("B").nodeParams.nodeId, nodes("C").nodeParams.nodeId), CltvExpiryDelta(555), Seq(DummyBlindedHop(55 msat, 55, CltvExpiryDelta(55)))) + OfferManager.InvoiceRequestActor.Route(route.hops :+ ChannelHop.dummy(nodes("C").nodeParams.nodeId, 55 msat, 55, CltvExpiryDelta(55)), recipientPaysFees = false, CltvExpiryDelta(555)) ) handleInvoiceRequest.replyTo ! InvoiceRequestActor.ApproveRequest(amount, receivingRoutes, pluginData_opt = Some(hex"eff0")) @@ -758,8 +769,11 @@ class PaymentIntegrationSpec extends IntegrationSpec { val alice = new EclairImpl(nodes("A")) alice.payOfferTrampoline(offer, amount, 1, nodes("B").nodeParams.nodeId, maxAttempts_opt = Some(1))(30 seconds).pipeTo(sender.ref) + nodes("D").router ! Router.FinalizeRoute(sender.ref, Router.PredefinedNodeRoute(amount, Seq(nodes("C").nodeParams.nodeId, nodes("D").nodeParams.nodeId))) + val route = sender.expectMsgType[Router.RouteResponse].routes.head + val handleInvoiceRequest = offerHandler.expectMessageType[HandleInvoiceRequest] - val receivingRoutes = Seq(ReceivingRoute(Seq(nodes("C").nodeParams.nodeId, nodes("D").nodeParams.nodeId), CltvExpiryDelta(500))) + val receivingRoutes = Seq(OfferManager.InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, CltvExpiryDelta(500))) handleInvoiceRequest.replyTo ! InvoiceRequestActor.ApproveRequest(amount, receivingRoutes, pluginData_opt = Some(hex"0123")) val handlePayment = offerHandler.expectMessageType[HandlePayment] @@ -797,6 +811,9 @@ class PaymentIntegrationSpec extends IntegrationSpec { val alice = new EclairImpl(nodes("A")) alice.payOfferBlocking(offer, amount, 1, maxAttempts_opt = Some(3))(30 seconds).pipeTo(sender.ref) + nodes("C").router ! Router.FinalizeRoute(sender.ref, Router.PredefinedNodeRoute(amount, Seq(nodes("B").nodeParams.nodeId, nodes("C").nodeParams.nodeId))) + val route = sender.expectMsgType[Router.RouteResponse].routes.head + val handleInvoiceRequest = offerHandler.expectMessageType[HandleInvoiceRequest] val scidDirCB = { probe.send(nodes("B").router, Router.GetChannels) @@ -804,7 +821,7 @@ class PaymentIntegrationSpec extends IntegrationSpec { ShortChannelIdDir(channelBC.nodeId1 == nodes("B").nodeParams.nodeId, channelBC.shortChannelId) } val receivingRoutes = Seq( - ReceivingRoute(Seq(nodes("B").nodeParams.nodeId, nodes("C").nodeParams.nodeId), CltvExpiryDelta(555), Seq(DummyBlindedHop(55 msat, 55, CltvExpiryDelta(55))), Some(scidDirCB)) + OfferManager.InvoiceRequestActor.Route(route.hops :+ ChannelHop.dummy(nodes("C").nodeParams.nodeId, 55 msat, 55, CltvExpiryDelta(55)), recipientPaysFees = false, CltvExpiryDelta(555), Some(scidDirCB)) ) handleInvoiceRequest.replyTo ! InvoiceRequestActor.ApproveRequest(amount, receivingRoutes) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala b/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala index 9be6def1bd..30e9091349 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala @@ -93,7 +93,7 @@ object MinimalNodeFixture extends Assertions with Eventually with IntegrationPat val watcherTyped = watcher.ref.toTyped[ZmqWatcher.Command] val register = system.actorOf(Register.props(), "register") val router = system.actorOf(Router.props(nodeParams, watcherTyped), "router") - val offerManager = system.spawn(OfferManager(nodeParams, router, 1 minute), "offer-manager") + val offerManager = system.spawn(OfferManager(nodeParams, 1 minute), "offer-manager") val paymentHandler = system.actorOf(PaymentHandler.props(nodeParams, register, offerManager), "payment-handler") val relayer = system.actorOf(Relayer.props(nodeParams, router, register, paymentHandler), "relayer") val txPublisherFactory = Channel.SimpleTxPublisherFactory(nodeParams, bitcoinClient) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/payment/OfferPaymentSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/payment/OfferPaymentSpec.scala index 5dc0ed6dc3..6d4e211e2f 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/payment/OfferPaymentSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/payment/OfferPaymentSpec.scala @@ -18,7 +18,7 @@ package fr.acinq.eclair.integration.basic.payment import akka.actor.typed.Behavior import akka.actor.typed.scaladsl.Behaviors -import akka.actor.typed.scaladsl.adapter.ClassicActorSystemOps +import akka.actor.typed.scaladsl.adapter.{ClassicActorRefOps, ClassicActorSystemOps} import akka.testkit.TestProbe import com.softwaremill.quicklens.ModifyPimp import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey @@ -33,9 +33,11 @@ import fr.acinq.eclair.message.OnionMessages import fr.acinq.eclair.message.OnionMessages.{IntermediateNode, Recipient, buildRoute} import fr.acinq.eclair.payment._ import fr.acinq.eclair.payment.offer.OfferManager -import fr.acinq.eclair.payment.receive.MultiPartHandler.{DummyBlindedHop, ReceivingRoute} +import fr.acinq.eclair.payment.offer.OfferManager.InvoiceRequestActor import fr.acinq.eclair.payment.send.OfferPayment import fr.acinq.eclair.payment.send.PaymentInitiator.{SendPaymentToNode, SendSpontaneousPayment} +import fr.acinq.eclair.router.Router +import fr.acinq.eclair.router.Router.ChannelHop import fr.acinq.eclair.testutils.FixtureSpec import fr.acinq.eclair.wire.protocol.OfferTypes.{Offer, OfferPaths} import fr.acinq.eclair.wire.protocol.{IncorrectOrUnknownPaymentDetails, InvalidOnionBlinding} @@ -151,10 +153,10 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { } } - def offerHandler(amount: MilliSatoshi, routes: Seq[ReceivingRoute]): Behavior[OfferManager.HandlerCommand] = { + def offerHandler(amount: MilliSatoshi, routes: Seq[InvoiceRequestActor.Route]): Behavior[OfferManager.HandlerCommand] = { Behaviors.receiveMessage { case OfferManager.HandleInvoiceRequest(replyTo, _) => - replyTo ! OfferManager.InvoiceRequestActor.ApproveRequest(amount, routes) + replyTo ! InvoiceRequestActor.ApproveRequest(amount, routes) Behaviors.same case OfferManager.HandlePayment(replyTo, _, _) => replyTo ! OfferManager.PaymentActor.AcceptPayment() @@ -162,7 +164,7 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { } } - def sendOfferPayment(f: FixtureParam, payer: MinimalNodeFixture, recipient: MinimalNodeFixture, amount: MilliSatoshi, routes: Seq[ReceivingRoute], maxAttempts: Int = 1): (Offer, PaymentEvent) = { + def sendOfferPayment(f: FixtureParam, payer: MinimalNodeFixture, recipient: MinimalNodeFixture, amount: MilliSatoshi, routes: Seq[InvoiceRequestActor.Route], maxAttempts: Int = 1): (Offer, PaymentEvent) = { import f._ val sender = TestProbe("sender") @@ -175,16 +177,15 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { (offer, sender.expectMsgType[PaymentEvent]) } - def sendPrivateOfferPayment(f: FixtureParam, payer: MinimalNodeFixture, recipient: MinimalNodeFixture, amount: MilliSatoshi, routes: Seq[ReceivingRoute], maxAttempts: Int = 1): (Offer, PaymentEvent) = { + def sendPrivateOfferPayment(f: FixtureParam, payer: MinimalNodeFixture, recipient: MinimalNodeFixture, amount: MilliSatoshi, routes: Seq[InvoiceRequestActor.Route], maxAttempts: Int = 1): (Offer, PaymentEvent) = { import f._ val sender = TestProbe("sender") val recipientKey = randomKey() val pathId = randomBytes32() val offerPaths = routes.map(route => { - val ourNodeId = route.nodes.last - val intermediateNodes = route.nodes.dropRight(1).map(IntermediateNode(_)) ++ route.dummyHops.map(_ => IntermediateNode(ourNodeId)) - buildRoute(randomKey(), intermediateNodes, Recipient(ourNodeId, Some(pathId))).route + val intermediateNodes = route.hops.map(hop => IntermediateNode(hop.nodeId)) + buildRoute(randomKey(), intermediateNodes, Recipient(recipient.nodeId, Some(pathId))).route }) val offer = Offer(None, Some("test"), recipientKey.publicKey, Features.empty, recipient.nodeParams.chainHash, additionalTlvs = Set(OfferPaths(offerPaths))) val handler = recipient.system.spawnAnonymous(offerHandler(amount, routes)) @@ -195,7 +196,7 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { (offer, sender.expectMsgType[PaymentEvent]) } - def sendOfferPaymentWithInvalidAmount(f: FixtureParam, payer: MinimalNodeFixture, recipient: MinimalNodeFixture, payerAmount: MilliSatoshi, recipientAmount: MilliSatoshi, routes: Seq[ReceivingRoute]): PaymentFailed = { + def sendOfferPaymentWithInvalidAmount(f: FixtureParam, payer: MinimalNodeFixture, recipient: MinimalNodeFixture, payerAmount: MilliSatoshi, recipientAmount: MilliSatoshi, routes: Seq[InvoiceRequestActor.Route]): PaymentFailed = { import f._ val sender = TestProbe("sender") @@ -225,30 +226,81 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { import f._ val amount = 25_000_000 msat - val routes = Seq(ReceivingRoute(Seq(bob.nodeId, carol.nodeId), maxFinalExpiryDelta)) + + val sender = TestProbe() + carol.router ! Router.FinalizeRoute(sender.ref.toTyped, Router.PredefinedNodeRoute(amount, Seq(bob.nodeId, carol.nodeId))) + val route = sender.expectMsgType[Router.RouteResponse].routes.head + + val routes = Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, maxFinalExpiryDelta)) val (offer, result) = sendOfferPayment(f, alice, carol, amount, routes) val payment = verifyPaymentSuccess(offer, amount, result) assert(payment.parts.length == 1) + assert(payment.parts.head.feesPaid > 0.msat) + } + + test("send blinded payment a->b->c, hidden fees") { f => + import f._ + + val amount = 25_000_000 msat + + val sender = TestProbe() + carol.router ! Router.FinalizeRoute(sender.ref.toTyped, Router.PredefinedNodeRoute(amount, Seq(bob.nodeId, carol.nodeId))) + val route = sender.expectMsgType[Router.RouteResponse].routes.head + + val routes = Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = true, maxFinalExpiryDelta)) + val (offer, result) = sendOfferPayment(f, alice, carol, amount, routes) + val payment = verifyPaymentSuccess(offer, amount, result) + assert(payment.parts.length == 1) + assert(payment.parts.head.feesPaid == 0.msat) } test("send blinded multi-part payment a->b->c") { f => import f._ val amount = 125_000_000 msat + + val sender = TestProbe() + carol.router ! Router.FinalizeRoute(sender.ref.toTyped, Router.PredefinedNodeRoute(10_000_000 msat, Seq(bob.nodeId, carol.nodeId))) + val route = sender.expectMsgType[Router.RouteResponse].routes.head + + val routes = Seq( + InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, maxFinalExpiryDelta), + InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, maxFinalExpiryDelta), + ) + val (offer, result) = sendOfferPayment(f, alice, carol, amount, routes, maxAttempts = 3) + val payment = verifyPaymentSuccess(offer, amount, result) + assert(payment.parts.length == 2) + assert(payment.parts.forall(_.feesPaid > 0.msat)) + } + + test("send blinded multi-part payment a->b->c, hidden fees") { f => + import f._ + + val amount = 125_000_000 msat + + val sender = TestProbe() + carol.router ! Router.FinalizeRoute(sender.ref.toTyped, Router.PredefinedNodeRoute(10_000_000 msat, Seq(bob.nodeId, carol.nodeId))) + val route = sender.expectMsgType[Router.RouteResponse].routes.head + val routes = Seq( - ReceivingRoute(Seq(bob.nodeId, carol.nodeId), maxFinalExpiryDelta), - ReceivingRoute(Seq(bob.nodeId, carol.nodeId), maxFinalExpiryDelta), + InvoiceRequestActor.Route(route.hops, recipientPaysFees = true, maxFinalExpiryDelta), + InvoiceRequestActor.Route(route.hops, recipientPaysFees = true, maxFinalExpiryDelta), ) val (offer, result) = sendOfferPayment(f, alice, carol, amount, routes, maxAttempts = 3) val payment = verifyPaymentSuccess(offer, amount, result) assert(payment.parts.length == 2) + assert(payment.parts.forall(_.feesPaid == 0.msat)) } test("send blinded multi-part payment a->b->c (single channel a->b)", Tag(PrivateChannels)) { f => import f._ + val sender = TestProbe() + carol.router ! Router.FinalizeRoute(sender.ref.toTyped, Router.PredefinedNodeRoute(10_000_000 msat, Seq(bob.nodeId, carol.nodeId))) + val route = sender.expectMsgType[Router.RouteResponse].routes.head + // Carol advertises a single blinded path from Bob to herself. - val routes = Seq(ReceivingRoute(Seq(bob.nodeId, carol.nodeId), maxFinalExpiryDelta)) + val routes = Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, maxFinalExpiryDelta)) // We make a first set of payments to ensure channels have less than 50 000 sat on Bob's side. Seq(50_000_000 msat, 50_000_000 msat).foreach(amount => { @@ -276,9 +328,13 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { val channelId_bc_1 = openChannel(bob, carol, 250_000 sat).channelId waitForChannelCreatedBC(f, channelId_bc_1) - val route = ReceivingRoute(Seq(bob.nodeId, carol.nodeId), maxFinalExpiryDelta) + val sender = TestProbe() + carol.router ! Router.FinalizeRoute(sender.ref.toTyped, Router.PredefinedNodeRoute(10_000_000 msat, Seq(bob.nodeId, carol.nodeId))) + val route = sender.expectMsgType[Router.RouteResponse].routes.head + + val routes = Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, maxFinalExpiryDelta)) val amount1 = 150_000_000 msat - val (offer, result) = sendPrivateOfferPayment(f, alice, carol, amount1, Seq(route), maxAttempts = 3) + val (offer, result) = sendPrivateOfferPayment(f, alice, carol, amount1, routes, maxAttempts = 3) val payment = verifyPaymentSuccess(offer, amount1, result) assert(payment.parts.length > 1) } @@ -286,30 +342,74 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { test("send blinded payment a->b->c with dummy hops") { f => import f._ + val sender = TestProbe() + carol.router ! Router.FinalizeRoute(sender.ref.toTyped, Router.PredefinedNodeRoute(10_000_000 msat, Seq(bob.nodeId, carol.nodeId))) + val route = sender.expectMsgType[Router.RouteResponse].routes.head + + val amount = 125_000_000 msat + val routes = Seq( + InvoiceRequestActor.Route(route.hops :+ ChannelHop.dummy(carol.nodeId, 150 msat, 0, CltvExpiryDelta(50)), recipientPaysFees = false, maxFinalExpiryDelta), + InvoiceRequestActor.Route(route.hops ++ Seq(ChannelHop.dummy(carol.nodeId, 50 msat, 0, CltvExpiryDelta(20)), ChannelHop.dummy(carol.nodeId, 100 msat, 0, CltvExpiryDelta(30))), recipientPaysFees = false, maxFinalExpiryDelta), + ) + val (offer, result) = sendOfferPayment(f, alice, carol, amount, routes) + val payment = verifyPaymentSuccess(offer, amount, result) + assert(payment.parts.length == 2) + assert(payment.parts.forall(_.feesPaid > 0.msat)) + } + + test("send blinded payment a->b->c with dummy hops, hidden fees") { f => + import f._ + + val sender = TestProbe() + carol.router ! Router.FinalizeRoute(sender.ref.toTyped, Router.PredefinedNodeRoute(10_000_000 msat, Seq(bob.nodeId, carol.nodeId))) + val route = sender.expectMsgType[Router.RouteResponse].routes.head + val amount = 125_000_000 msat val routes = Seq( - ReceivingRoute(Seq(bob.nodeId, carol.nodeId), maxFinalExpiryDelta, Seq(DummyBlindedHop(150 msat, 0, CltvExpiryDelta(50)))), - ReceivingRoute(Seq(bob.nodeId, carol.nodeId), maxFinalExpiryDelta, Seq(DummyBlindedHop(50 msat, 0, CltvExpiryDelta(20)), DummyBlindedHop(100 msat, 0, CltvExpiryDelta(30)))), + InvoiceRequestActor.Route(route.hops :+ ChannelHop.dummy(carol.nodeId, 150 msat, 0, CltvExpiryDelta(50)), recipientPaysFees = true, maxFinalExpiryDelta), + InvoiceRequestActor.Route(route.hops ++ Seq(ChannelHop.dummy(carol.nodeId, 50 msat, 0, CltvExpiryDelta(20)), ChannelHop.dummy(carol.nodeId, 100 msat, 0, CltvExpiryDelta(30))), recipientPaysFees = true, maxFinalExpiryDelta), ) val (offer, result) = sendOfferPayment(f, alice, carol, amount, routes) val payment = verifyPaymentSuccess(offer, amount, result) assert(payment.parts.length == 2) + assert(payment.parts.forall(_.feesPaid == 0.msat)) } test("send blinded payment a->b->c through private channels", Tag(PrivateChannels)) { f => import f._ val amount = 50_000_000 msat - val routes = Seq(ReceivingRoute(Seq(bob.nodeId, carol.nodeId), maxFinalExpiryDelta)) + + val sender = TestProbe() + carol.router ! Router.FinalizeRoute(sender.ref.toTyped, Router.PredefinedNodeRoute(amount, Seq(bob.nodeId, carol.nodeId))) + val route = sender.expectMsgType[Router.RouteResponse].routes.head + + val routes = Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, maxFinalExpiryDelta)) val (offer, result) = sendPrivateOfferPayment(f, alice, carol, amount, routes) - verifyPaymentSuccess(offer, amount, result) + val payment = verifyPaymentSuccess(offer, amount, result) + assert(payment.parts.forall(_.feesPaid > 0.msat)) + } + + test("send blinded payment a->b->c through private channels, hidden fees", Tag(PrivateChannels)) { f => + import f._ + + val amount = 50_000_000 msat + + val sender = TestProbe() + carol.router ! Router.FinalizeRoute(sender.ref.toTyped, Router.PredefinedNodeRoute(amount, Seq(bob.nodeId, carol.nodeId))) + val route = sender.expectMsgType[Router.RouteResponse].routes.head + + val routes = Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = true, maxFinalExpiryDelta)) + val (offer, result) = sendPrivateOfferPayment(f, alice, carol, amount, routes) + val payment = verifyPaymentSuccess(offer, amount, result) + assert(payment.parts.forall(_.feesPaid == 0.msat)) } test("send blinded payment a->b") { f => import f._ val amount = 75_000_000 msat - val routes = Seq(ReceivingRoute(Seq(bob.nodeId), maxFinalExpiryDelta)) + val routes = Seq(InvoiceRequestActor.Route(Nil, recipientPaysFees = false, maxFinalExpiryDelta)) val (offer, result) = sendOfferPayment(f, alice, bob, amount, routes) val payment = verifyPaymentSuccess(offer, amount, result) assert(payment.parts.length == 1) @@ -319,17 +419,34 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { import f._ val amount = 250_000_000 msat - val routes = Seq(ReceivingRoute(Seq(bob.nodeId), maxFinalExpiryDelta, Seq(DummyBlindedHop(10 msat, 25, CltvExpiryDelta(24)), DummyBlindedHop(5 msat, 10, CltvExpiryDelta(36))))) + val routes = Seq(InvoiceRequestActor.Route(Seq(ChannelHop.dummy(bob.nodeId, 10 msat, 25, CltvExpiryDelta(24)), ChannelHop.dummy(bob.nodeId, 5 msat, 10, CltvExpiryDelta(36))), recipientPaysFees = false, maxFinalExpiryDelta)) + val (offer, result) = sendOfferPayment(f, alice, bob, amount, routes) + val payment = verifyPaymentSuccess(offer, amount, result) + assert(payment.parts.length == 1) + assert(payment.parts.forall(_.feesPaid > 0.msat)) + } + + test("send blinded payment a->b with dummy hops, hidden fees") { f => + import f._ + + val amount = 250_000_000 msat + val routes = Seq(InvoiceRequestActor.Route(Seq(ChannelHop.dummy(bob.nodeId, 10 msat, 25, CltvExpiryDelta(24)), ChannelHop.dummy(bob.nodeId, 5 msat, 10, CltvExpiryDelta(36))), recipientPaysFees = true, maxFinalExpiryDelta)) val (offer, result) = sendOfferPayment(f, alice, bob, amount, routes) val payment = verifyPaymentSuccess(offer, amount, result) assert(payment.parts.length == 1) + assert(payment.parts.forall(_.feesPaid == 0.msat)) } test("send fully blinded payment b->c") { f => import f._ val amount = 50_000_000 msat - val routes = Seq(ReceivingRoute(Seq(bob.nodeId, carol.nodeId), maxFinalExpiryDelta)) + + val sender = TestProbe() + carol.router ! Router.FinalizeRoute(sender.ref.toTyped, Router.PredefinedNodeRoute(amount, Seq(bob.nodeId, carol.nodeId))) + val route = sender.expectMsgType[Router.RouteResponse].routes.head + + val routes = Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, maxFinalExpiryDelta)) val (offer, result) = sendOfferPayment(f, bob, carol, amount, routes) val payment = verifyPaymentSuccess(offer, amount, result) assert(payment.parts.length == 1) @@ -342,8 +459,12 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { val channelId_bc_1 = openChannel(bob, carol, 200_000 sat).channelId waitForChannelCreatedBC(f, channelId_bc_1) + val sender = TestProbe() + carol.router ! Router.FinalizeRoute(sender.ref.toTyped, Router.PredefinedNodeRoute(50_000_000 msat, Seq(bob.nodeId, carol.nodeId))) + val route = sender.expectMsgType[Router.RouteResponse].routes.head + // Carol creates a blinded path using that channel. - val routes = Seq(ReceivingRoute(Seq(bob.nodeId, carol.nodeId), maxFinalExpiryDelta)) + val routes = Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, maxFinalExpiryDelta)) // We make a payment to ensure that the channel contains less than 150 000 sat on Bob's side. assert(sendPayment(bob, carol, 50_000_000 msat).isRight) @@ -363,7 +484,12 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { import f._ val amount = 50_000_000 msat - val routes = Seq(ReceivingRoute(Seq(bob.nodeId, carol.nodeId), maxFinalExpiryDelta, Seq(DummyBlindedHop(25 msat, 250, CltvExpiryDelta(75))))) + + val sender = TestProbe() + carol.router ! Router.FinalizeRoute(sender.ref.toTyped, Router.PredefinedNodeRoute(amount, Seq(bob.nodeId, carol.nodeId))) + val route = sender.expectMsgType[Router.RouteResponse].routes.head + + val routes = Seq(InvoiceRequestActor.Route(route.hops :+ ChannelHop.dummy(carol.nodeId, 25 msat, 250, CltvExpiryDelta(75)), recipientPaysFees = false, maxFinalExpiryDelta)) val (offer, result) = sendOfferPayment(f, bob, carol, amount, routes) val payment = verifyPaymentSuccess(offer, amount, result) assert(payment.parts.length == 1) @@ -383,9 +509,13 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { // We wait for Carol to receive information about the channel between Alice and Bob. waitForAllChannelUpdates(f, channelsCount = 2) + val sender = TestProbe() + carol.router ! Router.FinalizeRoute(sender.ref.toTyped, Router.PredefinedNodeRoute(10_000_000 msat, Seq(alice.nodeId, bob.nodeId, carol.nodeId))) + val route = sender.expectMsgType[Router.RouteResponse].routes.head + // Carol receives a first payment through those channels. { - val routes = Seq(ReceivingRoute(Seq(alice.nodeId, bob.nodeId, carol.nodeId), maxFinalExpiryDelta)) + val routes = Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, maxFinalExpiryDelta)) val amount1 = 100_000_000 msat val (offer, result) = sendOfferPayment(f, alice, carol, amount1, routes) val payment = verifyPaymentSuccess(offer, amount1, result) @@ -401,7 +531,7 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { // Carol receives a second payment that requires using MPP. { - val routes = Seq(ReceivingRoute(Seq(alice.nodeId, bob.nodeId, carol.nodeId), maxFinalExpiryDelta)) + val routes = Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, maxFinalExpiryDelta)) val amount2 = 200_000_000 msat val (offer, result) = sendOfferPayment(f, alice, carol, amount2, routes, maxAttempts = 3) val payment = verifyPaymentSuccess(offer, amount2, result) @@ -425,8 +555,12 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { // We wait for Carol to receive information about the channel between Alice and Bob. waitForAllChannelUpdates(f, channelsCount = 3) + val sender = TestProbe() + carol.router ! Router.FinalizeRoute(sender.ref.toTyped, Router.PredefinedNodeRoute(10_000_000 msat, Seq(alice.nodeId, bob.nodeId, carol.nodeId))) + val route = sender.expectMsgType[Router.RouteResponse].routes.head + // Carol receives a payment that requires using MPP. - val routes = Seq(ReceivingRoute(Seq(alice.nodeId, bob.nodeId, carol.nodeId), maxFinalExpiryDelta)) + val routes = Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, maxFinalExpiryDelta)) val amount = 300_000_000 msat val (offer, result) = sendOfferPayment(f, alice, carol, amount, routes, maxAttempts = 3) val payment = verifyPaymentSuccess(offer, amount, result) @@ -449,8 +583,12 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { // We wait for Carol to receive information about the channel between Alice and Bob. waitForAllChannelUpdates(f, channelsCount = 3) + val sender = TestProbe() + carol.router ! Router.FinalizeRoute(sender.ref.toTyped, Router.PredefinedNodeRoute(10_000_000 msat, Seq(alice.nodeId, bob.nodeId, carol.nodeId))) + val route = sender.expectMsgType[Router.RouteResponse].routes.head + // Carol receives a payment that requires using MPP. - val routes = Seq(ReceivingRoute(Seq(alice.nodeId, bob.nodeId, carol.nodeId), maxFinalExpiryDelta)) + val routes = Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, maxFinalExpiryDelta)) val amount = 200_000_000 msat val (offer, result) = sendOfferPayment(f, alice, carol, amount, routes, maxAttempts = 3) val payment = verifyPaymentSuccess(offer, amount, result) @@ -470,6 +608,9 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { import f._ val sender = TestProbe("sender") + carol.router ! Router.FinalizeRoute(sender.ref.toTyped, Router.PredefinedNodeRoute(75_000_000 msat, Seq(bob.nodeId, carol.nodeId))) + val route = sender.expectMsgType[Router.RouteResponse].routes.head + // Bob sends payments to Carol to reduce the liquidity on both of his channels. Seq(1, 2).foreach(_ => { sender.send(bob.paymentInitiator, SendSpontaneousPayment(50_000_000 msat, carol.nodeId, randomBytes32(), 1, routeParams = bob.routeParams)) @@ -477,7 +618,7 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { sender.expectMsgType[PaymentSent] }) // Bob now doesn't have enough funds to relay the payment. - val routes = Seq(ReceivingRoute(Seq(bob.nodeId, carol.nodeId), maxFinalExpiryDelta)) + val routes = Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, maxFinalExpiryDelta)) val (_, result) = sendOfferPayment(f, alice, carol, 75_000_000 msat, routes) verifyBlindedFailure(result, bob.nodeId) } @@ -485,7 +626,11 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { test("send blinded payment a->b->c using expired route") { f => import f._ - val routes = Seq(ReceivingRoute(Seq(bob.nodeId, carol.nodeId), CltvExpiryDelta(-500))) + val sender = TestProbe() + carol.router ! Router.FinalizeRoute(sender.ref.toTyped, Router.PredefinedNodeRoute(25_000_000 msat, Seq(bob.nodeId, carol.nodeId))) + val route = sender.expectMsgType[Router.RouteResponse].routes.head + + val routes = Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, CltvExpiryDelta(-500))) val (_, result) = sendOfferPayment(f, alice, carol, 25_000_000 msat, routes) verifyBlindedFailure(result, bob.nodeId) } @@ -495,7 +640,12 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { val payerAmount = 20_000_000 msat val recipientAmount = 25_000_000 msat - val routes = Seq(ReceivingRoute(Seq(bob.nodeId, carol.nodeId), maxFinalExpiryDelta)) + + val sender = TestProbe() + carol.router ! Router.FinalizeRoute(sender.ref.toTyped, Router.PredefinedNodeRoute(recipientAmount, Seq(bob.nodeId, carol.nodeId))) + val route = sender.expectMsgType[Router.RouteResponse].routes.head + + val routes = Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, maxFinalExpiryDelta)) // The amount is below what Carol expects. val payment = sendOfferPaymentWithInvalidAmount(f, alice, carol, payerAmount, recipientAmount, routes) verifyBlindedFailure(payment, bob.nodeId) @@ -506,7 +656,7 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { val payerAmount = 25_000_000 msat val recipientAmount = 50_000_000 msat - val routes = Seq(ReceivingRoute(Seq(bob.nodeId), maxFinalExpiryDelta)) + val routes = Seq(InvoiceRequestActor.Route(Nil, recipientPaysFees = false, maxFinalExpiryDelta)) // The amount is below what Bob expects: since he is both the introduction node and the final recipient, he sends // back a normal error. val payment = sendOfferPaymentWithInvalidAmount(f, alice, bob, payerAmount, recipientAmount, routes) @@ -522,7 +672,7 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { val payerAmount = 25_000_000 msat val recipientAmount = 50_000_000 msat - val routes = Seq(ReceivingRoute(Seq(bob.nodeId), maxFinalExpiryDelta, Seq(DummyBlindedHop(1 msat, 100, CltvExpiryDelta(48))))) + val routes = Seq(InvoiceRequestActor.Route(Seq(ChannelHop.dummy(bob.nodeId, 1 msat, 100, CltvExpiryDelta(48))), recipientPaysFees = false, maxFinalExpiryDelta)) // The amount is below what Bob expects: since he is both the introduction node and the final recipient, he sends // back a normal error. val payment = sendOfferPaymentWithInvalidAmount(f, alice, bob, payerAmount, recipientAmount, routes) @@ -538,7 +688,12 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { val payerAmount = 45_000_000 msat val recipientAmount = 50_000_000 msat - val routes = Seq(ReceivingRoute(Seq(bob.nodeId, carol.nodeId), maxFinalExpiryDelta)) + + val sender = TestProbe() + carol.router ! Router.FinalizeRoute(sender.ref.toTyped, Router.PredefinedNodeRoute(recipientAmount, Seq(bob.nodeId, carol.nodeId))) + val route = sender.expectMsgType[Router.RouteResponse].routes.head + + val routes = Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, maxFinalExpiryDelta)) // The amount is below what Carol expects. val payment = sendOfferPaymentWithInvalidAmount(f, bob, carol, payerAmount, recipientAmount, routes) assert(payment.failures.head.isInstanceOf[PaymentFailure]) @@ -559,7 +714,11 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { val compactOffer = Offer(None, Some("test"), recipientKey.publicKey, Features.empty, carol.nodeParams.chainHash, additionalTlvs = Set(OfferPaths(Seq(compactBlindedRoute)))) assert(compactOffer.toString.length < offer.toString.length) - val receivingRoute = ReceivingRoute(Seq(bob.nodeId, carol.nodeId), maxFinalExpiryDelta) + val sender = TestProbe() + carol.router ! Router.FinalizeRoute(sender.ref.toTyped, Router.PredefinedNodeRoute(amount, Seq(bob.nodeId, carol.nodeId))) + val route = sender.expectMsgType[Router.RouteResponse].routes.head + + val receivingRoute = InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, maxFinalExpiryDelta) val handler = carol.system.spawnAnonymous(offerHandler(amount, Seq(receivingRoute))) carol.offerManager ! OfferManager.RegisterOffer(compactOffer, Some(recipientKey), Some(pathId), handler) val offerPayment = alice.system.spawnAnonymous(OfferPayment(alice.nodeParams, alice.postman, alice.router, alice.register, alice.paymentInitiator)) @@ -576,9 +735,12 @@ class OfferPaymentSpec extends FixtureSpec with IntegrationPatience { val pathId = randomBytes32() val amount = 25_000_000 msat + carol.router ! Router.FinalizeRoute(sender.ref.toTyped, Router.PredefinedNodeRoute(amount, Seq(bob.nodeId, carol.nodeId))) + val route = sender.expectMsgType[Router.RouteResponse].routes.head + val offerPaths = Seq(OnionMessages.buildRoute(randomKey(), Seq(IntermediateNode(bob.nodeId)), Recipient(carol.nodeId, Some(pathId))).route) val offer = Offer.withPaths(None, Some("implicit node id"), offerPaths, Features.empty, carol.nodeParams.chainHash) - val handler = carol.system.spawnAnonymous(offerHandler(amount, Seq(ReceivingRoute(Seq(bob.nodeId, carol.nodeId), maxFinalExpiryDelta)))) + val handler = carol.system.spawnAnonymous(offerHandler(amount, Seq(InvoiceRequestActor.Route(route.hops, recipientPaysFees = false, maxFinalExpiryDelta)))) carol.offerManager ! OfferManager.RegisterOffer(offer, None, Some(pathId), handler) val offerPayment = alice.system.spawnAnonymous(OfferPayment(alice.nodeParams, alice.postman, alice.router, alice.register, alice.paymentInitiator)) val sendPaymentConfig = OfferPayment.SendPaymentConfig(None, connectDirectly = false, maxAttempts = 1, alice.routeParams, blocking = true) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/MultiPartHandlerSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/MultiPartHandlerSpec.scala index ac9df1e86f..00b8f6eea3 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/MultiPartHandlerSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/MultiPartHandlerSpec.scala @@ -16,7 +16,6 @@ package fr.acinq.eclair.payment -import akka.actor.Status import akka.actor.typed.scaladsl.adapter.actorRefAdapter import akka.testkit.{TestActorRef, TestProbe} import fr.acinq.bitcoin.scalacompat.{Block, ByteVector32, Crypto} @@ -31,8 +30,10 @@ import fr.acinq.eclair.payment.offer.OfferManager import fr.acinq.eclair.payment.receive.MultiPartHandler._ import fr.acinq.eclair.payment.receive.MultiPartPaymentFSM.HtlcPart import fr.acinq.eclair.payment.receive.{MultiPartPaymentFSM, PaymentHandler} +import fr.acinq.eclair.payment.relay.Relayer.RelayFees +import fr.acinq.eclair.router.BlindedRouteCreation.aggregatePaymentInfo import fr.acinq.eclair.router.Router -import fr.acinq.eclair.router.Router.{PaymentRouteNotFound, RouteResponse} +import fr.acinq.eclair.router.Router.ChannelHop import fr.acinq.eclair.wire.protocol.OfferTypes.{InvoiceRequest, Offer, PaymentInfo} import fr.acinq.eclair.wire.protocol.OnionPaymentPayloadTlv._ import fr.acinq.eclair.wire.protocol.PaymentOnion.FinalPayload @@ -82,7 +83,7 @@ class MultiPartHandlerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike lazy val handlerWithKeySend = TestActorRef[PaymentHandler](PaymentHandler.props(nodeParams.copy(features = featuresWithKeySend), register.ref, offerManager.ref)) lazy val handlerWithRouteBlinding = TestActorRef[PaymentHandler](PaymentHandler.props(nodeParams.copy(features = featuresWithRouteBlinding), register.ref, offerManager.ref)) - def createEmptyReceivingRoute(): Seq[ReceivingRoute] = Seq(ReceivingRoute(Seq(nodeParams.nodeId), CltvExpiryDelta(144))) + def createEmptyReceivingRoute(pathId: ByteVector): Seq[ReceivingRoute] = Seq(ReceivingRoute(Nil, pathId, CltvExpiryDelta(144), PaymentInfo(0 msat, 0, CltvExpiryDelta(0), 0 msat, 1_000_000_000 msat, Features.empty))) } override def withFixture(test: OneArgTest): Outcome = { @@ -168,9 +169,9 @@ class MultiPartHandlerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike val preimage = randomBytes32() val pathId = randomBytes32() val router = TestProbe() - sender.send(handlerWithRouteBlinding, ReceiveOfferPayment(sender.ref, privKey, invoiceReq, createEmptyReceivingRoute(), router.ref, preimage, pathId)) + sender.send(handlerWithRouteBlinding, ReceiveOfferPayment(sender.ref, privKey, invoiceReq, createEmptyReceivingRoute(pathId), preimage)) router.expectNoMessage(50 millis) - val invoice = sender.expectMsgType[CreateInvoiceActor.InvoiceCreated].invoice + val invoice = sender.expectMsgType[Bolt12Invoice] // Offer invoices shouldn't be stored in the DB until we receive a payment for it. assert(nodeParams.db.payments.getIncomingPayment(invoice.paymentHash).isEmpty) @@ -180,7 +181,7 @@ class MultiPartHandlerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike assert(receivePayment.paymentHash == invoice.paymentHash) assert(receivePayment.payload.pathId == pathId.bytes) val payment = IncomingBlindedPayment(MinimalBolt12Invoice(invoice.records), preimage, PaymentType.Blinded, TimestampMilli.now(), IncomingPaymentStatus.Pending) - receivePayment.replyTo ! GetIncomingPaymentActor.ProcessPayment(payment) + receivePayment.replyTo ! GetIncomingPaymentActor.ProcessPayment(payment, 0 msat) assert(register.expectMsgType[Register.Forward[CMD_FULFILL_HTLC]].message.id == finalPacket.add.id) val paymentReceived = eventListener.expectMsgType[PaymentReceived] @@ -266,27 +267,23 @@ class MultiPartHandlerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike import f._ val privKey = randomKey() - val offer = Offer(Some(25_000 msat), Some("a blinded coffee please"), privKey.publicKey, Features.empty, Block.RegtestGenesisBlock.hash) - val invoiceReq = InvoiceRequest(offer, 25_000 msat, 1, featuresWithRouteBlinding.bolt12Features(), randomKey(), Block.RegtestGenesisBlock.hash) - val router = TestProbe() + val amount = 25_000 msat + val offer = Offer(Some(amount), Some("a blinded coffee please"), privKey.publicKey, Features.empty, Block.RegtestGenesisBlock.hash) + val invoiceReq = InvoiceRequest(offer, amount, 1, featuresWithRouteBlinding.bolt12Features(), randomKey(), Block.RegtestGenesisBlock.hash) val (a, b, c, d) = (randomKey().publicKey, randomKey().publicKey, randomKey().publicKey, nodeParams.nodeId) - val hop_ab = Router.ChannelHop(ShortChannelId(1), a, b, Router.HopRelayParams.FromHint(Invoice.ExtraEdge(a, b, ShortChannelId(1), 1000 msat, 0, CltvExpiryDelta(100), 1 msat, None))) - val hop_bd = Router.ChannelHop(ShortChannelId(2), b, d, Router.HopRelayParams.FromHint(Invoice.ExtraEdge(b, d, ShortChannelId(2), 800 msat, 0, CltvExpiryDelta(50), 1 msat, None))) - val hop_cd = Router.ChannelHop(ShortChannelId(3), c, d, Router.HopRelayParams.FromHint(Invoice.ExtraEdge(c, d, ShortChannelId(3), 0 msat, 0, CltvExpiryDelta(75), 1 msat, None))) + val hop_ab = ChannelHop(ShortChannelId(1), a, b, Router.HopRelayParams.FromHint(Invoice.ExtraEdge(a, b, ShortChannelId(1), 1000 msat, 0, CltvExpiryDelta(100), 1 msat, None))) + val hop_bd = ChannelHop(ShortChannelId(2), b, d, Router.HopRelayParams.FromHint(Invoice.ExtraEdge(b, d, ShortChannelId(2), 800 msat, 0, CltvExpiryDelta(50), 1 msat, None))) + val hop_cd = ChannelHop(ShortChannelId(3), c, d, Router.HopRelayParams.FromHint(Invoice.ExtraEdge(c, d, ShortChannelId(3), 0 msat, 0, CltvExpiryDelta(75), 1 msat, None))) + val hops1 = Seq(hop_ab, hop_bd, ChannelHop.dummy(d, 150 msat, 0, CltvExpiryDelta(25))) + val hops2 = Seq(hop_cd, ChannelHop.dummy(d, 250 msat, 0, CltvExpiryDelta(10)), ChannelHop.dummy(d, 150 msat, 0, CltvExpiryDelta(80))) val receivingRoutes = Seq( - ReceivingRoute(Seq(a, b, d), CltvExpiryDelta(100), Seq(DummyBlindedHop(150 msat, 0, CltvExpiryDelta(25)))), - ReceivingRoute(Seq(c, d), CltvExpiryDelta(50), Seq(DummyBlindedHop(250 msat, 0, CltvExpiryDelta(10)), DummyBlindedHop(150 msat, 0, CltvExpiryDelta(80)))), - ReceivingRoute(Seq(d), CltvExpiryDelta(250)), + ReceivingRoute(hops1, randomBytes32(), CltvExpiryDelta(100), aggregatePaymentInfo(amount, hops1, nodeParams.channelConf.minFinalExpiryDelta)), + ReceivingRoute(hops2, randomBytes32(), CltvExpiryDelta(50), aggregatePaymentInfo(amount, hops2, nodeParams.channelConf.minFinalExpiryDelta)), + ReceivingRoute(Nil, randomBytes32(), CltvExpiryDelta(250), PaymentInfo(0 msat, 0, nodeParams.channelConf.minFinalExpiryDelta, 0 msat, amount, Features.empty)), ) - sender.send(handlerWithRouteBlinding, ReceiveOfferPayment(sender.ref, privKey, invoiceReq, receivingRoutes, router.ref, randomBytes32(), randomBytes32())) - val finalizeRoute1 = router.expectMsgType[Router.FinalizeRoute] - assert(finalizeRoute1.route == Router.PredefinedNodeRoute(25_000 msat, Seq(a, b, d))) - finalizeRoute1.replyTo ! RouteResponse(Seq(Router.Route(25_000 msat, Seq(hop_ab, hop_bd), None))) - val finalizeRoute2 = router.expectMsgType[Router.FinalizeRoute] - assert(finalizeRoute2.route == Router.PredefinedNodeRoute(25_000 msat, Seq(c, d))) - finalizeRoute2.replyTo ! RouteResponse(Seq(Router.Route(25_000 msat, Seq(hop_cd), None))) - val invoice = sender.expectMsgType[CreateInvoiceActor.InvoiceCreated].invoice - assert(invoice.amount == 25_000.msat) + sender.send(handlerWithRouteBlinding, ReceiveOfferPayment(sender.ref, privKey, invoiceReq, receivingRoutes, randomBytes32())) + val invoice = sender.expectMsgType[Bolt12Invoice] + assert(invoice.amount == amount) assert(invoice.nodeId == privKey.publicKey) assert(invoice.blindedPaths.nonEmpty) assert(invoice.description.contains("a blinded coffee please")) @@ -307,29 +304,6 @@ class MultiPartHandlerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike assert(invoice.blindedPaths.flatMap(_.route.encryptedPayloads.dropRight(1)).map(_.length).toSet.size == 1) } - test("Invoice generation with route blinding should fail when router returns an error") { f => - import f._ - - val privKey = randomKey() - val offer = Offer(Some(25_000 msat), Some("a blinded coffee please"), privKey.publicKey, Features.empty, Block.RegtestGenesisBlock.hash) - val invoiceReq = InvoiceRequest(offer, 25_000 msat, 1, featuresWithRouteBlinding.bolt12Features(), randomKey(), Block.RegtestGenesisBlock.hash) - val router = TestProbe() - val (a, b, c) = (randomKey().publicKey, randomKey().publicKey, nodeParams.nodeId) - val hop_ac = Router.ChannelHop(ShortChannelId(1), a, c, Router.HopRelayParams.FromHint(Invoice.ExtraEdge(a, c, ShortChannelId(1), 100 msat, 0, CltvExpiryDelta(50), 1 msat, None))) - val receivingRoutes = Seq( - ReceivingRoute(Seq(a, c), CltvExpiryDelta(100)), - ReceivingRoute(Seq(b, c), CltvExpiryDelta(100)), - ) - sender.send(handlerWithRouteBlinding, ReceiveOfferPayment(sender.ref, privKey, invoiceReq, receivingRoutes, router.ref, randomBytes32(), randomBytes32())) - val finalizeRoute1 = router.expectMsgType[Router.FinalizeRoute] - assert(finalizeRoute1.route == Router.PredefinedNodeRoute(25_000 msat, Seq(a, c))) - finalizeRoute1.replyTo ! RouteResponse(Seq(Router.Route(25_000 msat, Seq(hop_ac), None))) - val finalizeRoute2 = router.expectMsgType[Router.FinalizeRoute] - assert(finalizeRoute2.route == Router.PredefinedNodeRoute(25_000 msat, Seq(b, c))) - finalizeRoute2.replyTo ! PaymentRouteNotFound(new IllegalArgumentException("invalid route")) - sender.expectMsgType[CreateInvoiceActor.BlindedRouteCreationFailed] - } - test("Generated invoice contains the provided extra hops") { f => import f._ @@ -487,8 +461,8 @@ class MultiPartHandlerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike val nodeKey = randomKey() val offer = Offer(None, Some("a blinded coffee please"), nodeKey.publicKey, Features.empty, Block.RegtestGenesisBlock.hash) val invoiceReq = InvoiceRequest(offer, 5000 msat, 1, featuresWithRouteBlinding.bolt12Features(), randomKey(), Block.RegtestGenesisBlock.hash) - sender.send(handlerWithRouteBlinding, ReceiveOfferPayment(sender.ref, nodeKey, invoiceReq, createEmptyReceivingRoute(), TestProbe().ref, randomBytes32(), randomBytes32())) - val invoice = sender.expectMsgType[CreateInvoiceActor.InvoiceCreated].invoice + sender.send(handlerWithRouteBlinding, ReceiveOfferPayment(sender.ref, nodeKey, invoiceReq, createEmptyReceivingRoute(randomBytes32()), randomBytes32())) + val invoice = sender.expectMsgType[Bolt12Invoice] val add = UpdateAddHtlc(ByteVector32.One, 0, 5000 msat, invoice.paymentHash, defaultExpiry, TestConstants.emptyOnionPacket, None, 1.0, None) sender.send(handlerWithMpp, IncomingPaymentPacket.FinalPacket(add, FinalPayload.Standard.createPayload(add.amountMsat, add.amountMsat, add.cltvExpiry, randomBytes32(), None))) @@ -505,8 +479,8 @@ class MultiPartHandlerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike val invoiceReq = InvoiceRequest(offer, 5000 msat, 1, featuresWithRouteBlinding.bolt12Features(), randomKey(), Block.RegtestGenesisBlock.hash) val preimage = randomBytes32() val pathId = randomBytes32() - sender.send(handlerWithRouteBlinding, ReceiveOfferPayment(sender.ref, nodeKey, invoiceReq, createEmptyReceivingRoute(), TestProbe().ref, preimage, pathId)) - val invoice = sender.expectMsgType[CreateInvoiceActor.InvoiceCreated].invoice + sender.send(handlerWithRouteBlinding, ReceiveOfferPayment(sender.ref, nodeKey, invoiceReq, createEmptyReceivingRoute(pathId), preimage)) + val invoice = sender.expectMsgType[Bolt12Invoice] assert(nodeParams.db.payments.getIncomingPayment(invoice.paymentHash).isEmpty) val packet = createBlindedPacket(5000 msat, invoice.paymentHash, defaultExpiry, CltvExpiry(nodeParams.currentBlockHeight), pathId) @@ -515,7 +489,7 @@ class MultiPartHandlerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike assert(receivePayment.paymentHash == invoice.paymentHash) assert(receivePayment.payload.pathId == pathId.bytes) val payment = IncomingBlindedPayment(MinimalBolt12Invoice(invoice.records), preimage, PaymentType.Blinded, TimestampMilli.now(), IncomingPaymentStatus.Pending) - receivePayment.replyTo ! GetIncomingPaymentActor.ProcessPayment(payment) + receivePayment.replyTo ! GetIncomingPaymentActor.ProcessPayment(payment, 0 msat) register.expectMsgType[Register.Forward[CMD_FULFILL_HTLC]] assert(nodeParams.db.payments.getIncomingPayment(invoice.paymentHash).get.status.isInstanceOf[IncomingPaymentStatus.Received]) } @@ -528,8 +502,8 @@ class MultiPartHandlerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike val pathId = randomBytes(128) val offer = Offer(None, Some("a blinded coffee please"), nodeKey.publicKey, Features.empty, Block.RegtestGenesisBlock.hash) val invoiceReq = InvoiceRequest(offer, 5000 msat, 1, featuresWithRouteBlinding.bolt12Features(), randomKey(), Block.RegtestGenesisBlock.hash) - sender.send(handlerWithRouteBlinding, ReceiveOfferPayment(sender.ref, nodeKey, invoiceReq, createEmptyReceivingRoute(), TestProbe().ref, preimage, pathId)) - val invoice = sender.expectMsgType[CreateInvoiceActor.InvoiceCreated].invoice + sender.send(handlerWithRouteBlinding, ReceiveOfferPayment(sender.ref, nodeKey, invoiceReq, createEmptyReceivingRoute(pathId), preimage)) + val invoice = sender.expectMsgType[Bolt12Invoice] val packet = createBlindedPacket(5000 msat, invoice.paymentHash, defaultExpiry, CltvExpiry(nodeParams.currentBlockHeight), pathId) sender.send(handlerWithRouteBlinding, packet) @@ -548,8 +522,8 @@ class MultiPartHandlerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike val invoiceReq = InvoiceRequest(offer, 5000 msat, 1, featuresWithRouteBlinding.bolt12Features(), randomKey(), Block.RegtestGenesisBlock.hash) val preimage = randomBytes32() val pathId = randomBytes32() - sender.send(handlerWithRouteBlinding, ReceiveOfferPayment(sender.ref, nodeKey, invoiceReq, createEmptyReceivingRoute(), TestProbe().ref, preimage, pathId)) - val invoice = sender.expectMsgType[CreateInvoiceActor.InvoiceCreated].invoice + sender.send(handlerWithRouteBlinding, ReceiveOfferPayment(sender.ref, nodeKey, invoiceReq, createEmptyReceivingRoute(pathId), preimage)) + val invoice = sender.expectMsgType[Bolt12Invoice] // We test the case where the HTLC's cltv_expiry is lower than expected and doesn't meet the min_final_expiry_delta. val packet = createBlindedPacket(5000 msat, invoice.paymentHash, defaultExpiry - CltvExpiryDelta(1), defaultExpiry, pathId) @@ -558,7 +532,7 @@ class MultiPartHandlerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike assert(receivePayment.paymentHash == invoice.paymentHash) assert(receivePayment.payload.pathId == pathId.bytes) val payment = IncomingBlindedPayment(MinimalBolt12Invoice(invoice.records), preimage, PaymentType.Blinded, TimestampMilli.now(), IncomingPaymentStatus.Pending) - receivePayment.replyTo ! GetIncomingPaymentActor.ProcessPayment(payment) + receivePayment.replyTo ! GetIncomingPaymentActor.ProcessPayment(payment, 0 msat) val cmd = register.expectMsgType[Register.Forward[CMD_FAIL_HTLC]].message assert(cmd.reason == FailureReason.LocalFailure(IncorrectOrUnknownPaymentDetails(5000 msat, nodeParams.currentBlockHeight))) assert(nodeParams.db.payments.getIncomingPayment(invoice.paymentHash).isEmpty) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/MultiPartPaymentLifecycleSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/MultiPartPaymentLifecycleSpec.scala index f4a7375d98..4d791ac85b 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/MultiPartPaymentLifecycleSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/MultiPartPaymentLifecycleSpec.scala @@ -16,7 +16,7 @@ package fr.acinq.eclair.payment -import akka.actor.{ActorContext, ActorRef, Status, typed} +import akka.actor.{ActorContext, ActorRef} import akka.testkit.{TestFSMRef, TestProbe} import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.bitcoin.scalacompat.{Block, ByteVector32, Crypto, SatoshiLong} diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentInitiatorSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentInitiatorSpec.scala index 1ef679f196..79011ef984 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentInitiatorSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentInitiatorSpec.scala @@ -287,7 +287,7 @@ class PaymentInitiatorSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike def createBolt12Invoice(features: Features[Bolt12Feature], payerKey: PrivateKey): Bolt12Invoice = { val offer = Offer(None, Some("Bolt12 r0cks"), e, features, Block.RegtestGenesisBlock.hash) val invoiceRequest = InvoiceRequest(offer, finalAmount, 1, features, payerKey, Block.RegtestGenesisBlock.hash) - val blindedRoute = BlindedRouteCreation.createBlindedRouteWithoutHops(e, hex"2a2a2a2a", 1 msat, CltvExpiry(500_000)).route + val blindedRoute = BlindedRouteCreation.createBlindedRouteFromHops(Nil, e, hex"2a2a2a2a", 1 msat, CltvExpiry(500_000)).route val paymentInfo = OfferTypes.PaymentInfo(1_000 msat, 0, CltvExpiryDelta(24), 0 msat, finalAmount, Features.empty) Bolt12Invoice(invoiceRequest, paymentPreimage, priv_e.privateKey, 300 seconds, features, Seq(PaymentBlindedRoute(blindedRoute, paymentInfo))) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentPacketSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentPacketSpec.scala index 377c7350eb..7bb38b30ed 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentPacketSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentPacketSpec.scala @@ -215,7 +215,7 @@ class PaymentPacketSpec extends AnyFunSuite with BeforeAndAfterAll { val features = Features[Bolt12Feature](BasicMultiPartPayment -> Optional) val offer = Offer(None, Some("Bolt12 r0cks"), recipientKey.publicKey, features, Block.RegtestGenesisBlock.hash) val invoiceRequest = InvoiceRequest(offer, amount_bc, 1, features, randomKey(), Block.RegtestGenesisBlock.hash) - val blindedRoute = BlindedRouteCreation.createBlindedRouteWithoutHops(c, hex"deadbeef", 1 msat, CltvExpiry(500_000)).route + val blindedRoute = BlindedRouteCreation.createBlindedRouteFromHops(Nil, c, hex"deadbeef", 1 msat, CltvExpiry(500_000)).route val paymentInfo = PaymentInfo(0 msat, 0, CltvExpiryDelta(0), 1 msat, amount_bc, Features.empty) val invoice = Bolt12Invoice(invoiceRequest, paymentPreimage, recipientKey, 300 seconds, features, Seq(PaymentBlindedRoute(blindedRoute, paymentInfo))) val resolvedPaths = invoice.blindedPaths.map(path => { @@ -484,7 +484,7 @@ class PaymentPacketSpec extends AnyFunSuite with BeforeAndAfterAll { val offer = Offer(None, Some("Bolt12 r0cks"), c, features, Block.RegtestGenesisBlock.hash) val invoiceRequest = InvoiceRequest(offer, amount_bc, 1, features, randomKey(), Block.RegtestGenesisBlock.hash) // We send the wrong blinded payload to the introduction node. - val tmpBlindedRoute = BlindedRouteCreation.createBlindedRouteFromHops(Seq(channelHopFromUpdate(b, c, channelUpdate_bc)), hex"deadbeef", 1 msat, CltvExpiry(500_000)).route + val tmpBlindedRoute = BlindedRouteCreation.createBlindedRouteFromHops(Seq(channelHopFromUpdate(b, c, channelUpdate_bc)), c, hex"deadbeef", 1 msat, CltvExpiry(500_000)).route val blindedRoute = tmpBlindedRoute.copy(blindedHops = tmpBlindedRoute.blindedHops.reverse) val paymentInfo = OfferTypes.PaymentInfo(fee_b, 0, channelUpdate_bc.cltvExpiryDelta, 0 msat, amount_bc, Features.empty) val invoice = Bolt12Invoice(invoiceRequest, paymentPreimage, priv_c.privateKey, 300 seconds, features, Seq(PaymentBlindedRoute(blindedRoute, paymentInfo))) @@ -531,26 +531,6 @@ class PaymentPacketSpec extends AnyFunSuite with BeforeAndAfterAll { assert(failure == FinalIncorrectCltvExpiry(payment.cmd.cltvExpiry - CltvExpiryDelta(12))) } - test("fail to decrypt blinded payment at the final node when amount is too low") { - val (route, recipient) = shortBlindedHops() - val Right(payment) = buildOutgoingPayment(TestConstants.emptyOrigin, paymentHash, route, recipient, 1.0) - assert(payment.outgoingChannel == channelUpdate_cd.shortChannelId) - assert(payment.cmd.amount == amount_cd) - - // A smaller amount is sent to d, who doesn't know that it's invalid. - val add_d = UpdateAddHtlc(randomBytes32(), 0, amount_de, paymentHash, payment.cmd.cltvExpiry, payment.cmd.onion, payment.cmd.nextPathKey_opt, 1.0, payment.cmd.fundingFee_opt) - val Right(relay_d@ChannelRelayPacket(_, payload_d, packet_e)) = decrypt(add_d, priv_d.privateKey, Features(RouteBlinding -> Optional)) - assert(payload_d.outgoing.contains(channelUpdate_de.shortChannelId)) - assert(relay_d.amountToForward < amount_de) - assert(payload_d.isInstanceOf[IntermediatePayload.ChannelRelay.Blinded]) - val pathKey_e = payload_d.asInstanceOf[IntermediatePayload.ChannelRelay.Blinded].nextPathKey - - // When e receives a smaller amount than expected, it rejects the payment. - val add_e = UpdateAddHtlc(randomBytes32(), 0, relay_d.amountToForward, paymentHash, relay_d.outgoingCltv, packet_e, Some(pathKey_e), 1.0, None) - val Left(failure) = decrypt(add_e, priv_e.privateKey, Features(RouteBlinding -> Optional)) - assert(failure.isInstanceOf[InvalidOnionBlinding]) - } - test("fail to decrypt blinded payment at the final node when expiry is too low") { val (route, recipient) = shortBlindedHops() val Right(payment) = buildOutgoingPayment(TestConstants.emptyOrigin, paymentHash, route, recipient, 1.0) @@ -786,7 +766,7 @@ object PaymentPacketSpec { val amount_ab = amount_bc + fee_b def buildOutgoingBlindedPaymentAB(paymentHash: ByteVector32, routeExpiry: CltvExpiry = CltvExpiry(500_000)): Either[OutgoingPaymentError, OutgoingPaymentPacket] = { - val blindedRoute = BlindedRouteCreation.createBlindedRouteWithoutHops(b, hex"deadbeef", 1.msat, routeExpiry).route + val blindedRoute = BlindedRouteCreation.createBlindedRouteFromHops(Nil, b, hex"deadbeef", 1.msat, routeExpiry).route val finalPayload = NodePayload(blindedRoute.firstNode.blindedPublicKey, OutgoingBlindedPerHopPayload.createFinalPayload(finalAmount, finalAmount, finalExpiry, blindedRoute.firstNode.encryptedPayload)) val onion = buildOnion(Seq(finalPayload), paymentHash, Some(PaymentOnionCodecs.paymentOnionPayloadLength)).toOption.get // BOLT 2 requires that associatedData == paymentHash val cmd = CMD_ADD_HTLC(ActorRef.noSender, finalAmount, paymentHash, finalExpiry, onion.packet, Some(blindedRoute.firstPathKey), 1.0, None, TestConstants.emptyOrigin, commit = true) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/offer/OfferManagerSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/offer/OfferManagerSpec.scala index bfc9008bca..0f0b8596df 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/offer/OfferManagerSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/offer/OfferManagerSpec.scala @@ -28,15 +28,16 @@ import fr.acinq.eclair.payment.Bolt12Invoice import fr.acinq.eclair.payment.offer.OfferManager._ import fr.acinq.eclair.payment.receive.MultiPartHandler import fr.acinq.eclair.payment.receive.MultiPartHandler.GetIncomingPaymentActor.{ProcessPayment, RejectPayment} -import fr.acinq.eclair.payment.receive.MultiPartHandler.ReceivingRoute +import fr.acinq.eclair.router.Router.ChannelHop import fr.acinq.eclair.wire.protocol.OfferTypes.{InvoiceRequest, Offer} import fr.acinq.eclair.wire.protocol.RouteBlindingEncryptedDataCodecs.RouteBlindingDecryptedData import fr.acinq.eclair.wire.protocol._ -import fr.acinq.eclair.{CltvExpiry, CltvExpiryDelta, Features, MilliSatoshi, MilliSatoshiLong, NodeParams, TestConstants, randomBytes32, randomKey} +import fr.acinq.eclair.{CltvExpiry, CltvExpiryDelta, Features, MilliSatoshi, MilliSatoshiLong, NodeParams, TestConstants, amountAfterFee, randomBytes32, randomKey} import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.{Outcome, Tag} import scodec.bits.{ByteVector, HexStringSyntax} +import scala.annotation.tailrec import scala.concurrent.duration.DurationInt class OfferManagerSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("application")) with FixtureAnyFunSuiteLike { @@ -49,7 +50,7 @@ class OfferManagerSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app val nodeParams = TestConstants.Alice.nodeParams val router = akka.testkit.TestProbe()(system.toClassic) val paymentTimeout = if (test.tags.contains(ShortPaymentTimeout)) 100 millis else 5 seconds - val offerManager = testKit.spawn(OfferManager(nodeParams, router.ref, paymentTimeout)) + val offerManager = testKit.spawn(OfferManager(nodeParams, paymentTimeout)) val postman = TestProbe[Postman.Command]() val paymentHandler = TestProbe[MultiPartHandler.GetIncomingPaymentActor.Command]() try { @@ -69,13 +70,13 @@ class OfferManagerSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app offerManager ! RequestInvoice(messagePayload, offerKey, postman) } - def receiveInvoice(f: FixtureParam, amount: MilliSatoshi, payerKey: PrivateKey, pathNodeId: PublicKey, handler: TestProbe[HandlerCommand], pluginData_opt: Option[ByteVector] = None): Bolt12Invoice = { + def receiveInvoice(f: FixtureParam, amount: MilliSatoshi, payerKey: PrivateKey, pathNodeId: PublicKey, handler: TestProbe[HandlerCommand], pluginData_opt: Option[ByteVector] = None, hops: Seq[ChannelHop] = Nil, hideFees: Boolean = false): Bolt12Invoice = { import f._ val handleInvoiceRequest = handler.expectMessageType[HandleInvoiceRequest] assert(handleInvoiceRequest.invoiceRequest.isValid) assert(handleInvoiceRequest.invoiceRequest.payerId == payerKey.publicKey) - handleInvoiceRequest.replyTo ! InvoiceRequestActor.ApproveRequest(amount, Seq(ReceivingRoute(Seq(nodeParams.nodeId), CltvExpiryDelta(1000), Nil)), pluginData_opt) + handleInvoiceRequest.replyTo ! InvoiceRequestActor.ApproveRequest(amount, Seq(InvoiceRequestActor.Route(hops, hideFees, CltvExpiryDelta(1000))), pluginData_opt) val invoiceMessage = postman.expectMessageType[Postman.SendMessage] val Right(invoice) = Bolt12Invoice.validate(invoiceMessage.message.get[OnionMessagePayloadTlv.Invoice].get.tlvs) assert(invoice.validateFor(handleInvoiceRequest.invoiceRequest, pathNodeId).isRight) @@ -84,12 +85,24 @@ class OfferManagerSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app invoice } + /** Decrypt the provided encrypted payloads, assuming we're using only dummy hops for the target node. */ + @tailrec + private def decryptBlindedPayload(nodeKey: PrivateKey, pathKey: PublicKey, encryptedPayloads: Seq[ByteVector]): TlvStream[RouteBlindingEncryptedDataTlv] = { + if (encryptedPayloads.size == 1) { + val Right(RouteBlindingDecryptedData(encryptedDataTlvs, _)) = RouteBlindingEncryptedDataCodecs.decode(nodeKey, pathKey, encryptedPayloads.head) + encryptedDataTlvs + } else { + val Right(RouteBlindingDecryptedData(_, nextPathKey)) = RouteBlindingEncryptedDataCodecs.decode(nodeKey, pathKey, encryptedPayloads.head) + decryptBlindedPayload(nodeKey, nextPathKey, encryptedPayloads.tail) + } + } + def createPaymentPayload(f: FixtureParam, invoice: Bolt12Invoice): PaymentOnion.FinalPayload.Blinded = { import f._ assert(invoice.blindedPaths.length == 1) val blindedPath = invoice.blindedPaths.head.route - val Right(RouteBlindingDecryptedData(encryptedDataTlvs, _)) = RouteBlindingEncryptedDataCodecs.decode(nodeParams.privateKey, blindedPath.firstPathKey, blindedPath.encryptedPayloads.head) + val encryptedDataTlvs = decryptBlindedPayload(nodeParams.privateKey, blindedPath.firstPathKey, blindedPath.encryptedPayloads) val paymentTlvs = TlvStream[OnionPaymentPayloadTlv]( OnionPaymentPayloadTlv.AmountToForward(invoice.amount), OnionPaymentPayloadTlv.TotalAmount(invoice.amount), @@ -111,12 +124,12 @@ class OfferManagerSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app val invoice = receiveInvoice(f, amount, payerKey, nodeParams.nodeId, handler, pluginData_opt = Some(hex"deadbeef")) // Pay invoice. val paymentPayload = createPaymentPayload(f, invoice) - offerManager ! ReceivePayment(paymentHandler.ref, invoice.paymentHash, paymentPayload) + offerManager ! ReceivePayment(paymentHandler.ref, invoice.paymentHash, paymentPayload, amount) val handlePayment = handler.expectMessageType[HandlePayment] assert(handlePayment.offerId == offer.offerId) assert(handlePayment.pluginData_opt.contains(hex"deadbeef")) handlePayment.replyTo ! PaymentActor.AcceptPayment() - val ProcessPayment(incomingPayment) = paymentHandler.expectMessageType[ProcessPayment] + val ProcessPayment(incomingPayment, _) = paymentHandler.expectMessageType[ProcessPayment] assert(Crypto.sha256(incomingPayment.paymentPreimage) == invoice.paymentHash) assert(incomingPayment.invoice.nodeId == nodeParams.nodeId) assert(incomingPayment.invoice.paymentHash == invoice.paymentHash) @@ -205,7 +218,7 @@ class OfferManagerSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app val invoice2 = receiveInvoice(f, amount, payerKey, nodeParams.nodeId, handler) // Try paying invoice #1 with data from invoice #2. val paymentPayload = createPaymentPayload(f, invoice2) - offerManager ! ReceivePayment(paymentHandler.ref, invoice1.paymentHash, paymentPayload) + offerManager ! ReceivePayment(paymentHandler.ref, invoice1.paymentHash, paymentPayload, amount) paymentHandler.expectMessageType[RejectPayment] handler.expectNoMessage(50 millis) } @@ -228,7 +241,7 @@ class OfferManagerSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app val invalidPaymentPayload = paymentPayload.copy( blindedRecords = TlvStream(paymentPayload.blindedRecords.records.filterNot(_.isInstanceOf[RouteBlindingEncryptedDataTlv.PathId]) + RouteBlindingEncryptedDataTlv.PathId(invalidPathId)) ) - offerManager ! ReceivePayment(paymentHandler.ref, invoice.paymentHash, invalidPaymentPayload) + offerManager ! ReceivePayment(paymentHandler.ref, invoice.paymentHash, invalidPaymentPayload, amount) paymentHandler.expectMessageType[RejectPayment] handler.expectNoMessage(50 millis) } @@ -246,7 +259,7 @@ class OfferManagerSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app val invoice = receiveInvoice(f, amount, payerKey, nodeParams.nodeId, handler) // Try paying the invoice, but the plugin handler doesn't respond. val paymentPayload = createPaymentPayload(f, invoice) - offerManager ! ReceivePayment(paymentHandler.ref, invoice.paymentHash, paymentPayload) + offerManager ! ReceivePayment(paymentHandler.ref, invoice.paymentHash, paymentPayload, amount) handler.expectMessageType[HandlePayment] assert(paymentHandler.expectMessageType[RejectPayment].reason == "plugin timeout") } @@ -264,10 +277,59 @@ class OfferManagerSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("app val invoice = receiveInvoice(f, amount, payerKey, nodeParams.nodeId, handler) // Try paying the invoice, but the plugin handler rejects the payment. val paymentPayload = createPaymentPayload(f, invoice) - offerManager ! ReceivePayment(paymentHandler.ref, invoice.paymentHash, paymentPayload) + offerManager ! ReceivePayment(paymentHandler.ref, invoice.paymentHash, paymentPayload, amount) val handlePayment = handler.expectMessageType[HandlePayment] handlePayment.replyTo ! PaymentActor.RejectPayment("internal error") assert(paymentHandler.expectMessageType[RejectPayment].reason == "internal error") } + test("pay offer without hidden fee") { f => + import f._ + + val handler = TestProbe[HandlerCommand]() + val amount = 10_000_000 msat + val offer = Offer(Some(amount), Some("offer"), nodeParams.nodeId, Features.empty, nodeParams.chainHash) + offerManager ! RegisterOffer(offer, Some(nodeParams.privateKey), None, handler.ref) + // Request invoice. + val payerKey = randomKey() + requestInvoice(payerKey, offer, nodeParams.privateKey, amount, offerManager, postman.ref) + val invoice = receiveInvoice(f, amount, payerKey, nodeParams.nodeId, handler) + val paymentPayload = createPaymentPayload(f, invoice) + offerManager ! ReceivePayment(paymentHandler.ref, invoice.paymentHash, paymentPayload, amount) + + val handlePayment = handler.expectMessageType[HandlePayment] + assert(handlePayment.offerId == offer.offerId) + handlePayment.replyTo ! PaymentActor.AcceptPayment() + val ProcessPayment(incomingPayment, maxRecipientPathFees) = paymentHandler.expectMessageType[ProcessPayment] + assert(Crypto.sha256(incomingPayment.paymentPreimage) == invoice.paymentHash) + assert(incomingPayment.invoice.nodeId == nodeParams.nodeId) + assert(incomingPayment.invoice.paymentHash == invoice.paymentHash) + assert(maxRecipientPathFees == 0.msat) + } + + test("pay offer with hidden fees") { f => + import f._ + + val handler = TestProbe[HandlerCommand]() + val amount = 10_000_000 msat + val offer = Offer(Some(amount), Some("offer"), nodeParams.nodeId, Features.empty, nodeParams.chainHash) + offerManager ! RegisterOffer(offer, Some(nodeParams.privateKey), None, handler.ref) + // Request invoice. + val payerKey = randomKey() + requestInvoice(payerKey, offer, nodeParams.privateKey, amount, offerManager, postman.ref) + val invoice = receiveInvoice(f, amount, payerKey, nodeParams.nodeId, handler, hops = List(ChannelHop.dummy(nodeParams.nodeId, 1000 msat, 200, CltvExpiryDelta(144))), hideFees = true) + // Sending less than the full amount as fees are paid by the recipient + val paymentPayload = createPaymentPayload(f, invoice) + val amountReceived = amountAfterFee(1000 msat, 200, amount) + offerManager ! ReceivePayment(paymentHandler.ref, invoice.paymentHash, paymentPayload, amountReceived) + + val handlePayment = handler.expectMessageType[HandlePayment] + assert(handlePayment.offerId == offer.offerId) + handlePayment.replyTo ! PaymentActor.AcceptPayment() + val ProcessPayment(incomingPayment, maxRecipientPathFees) = paymentHandler.expectMessageType[ProcessPayment] + assert(Crypto.sha256(incomingPayment.paymentPreimage) == invoice.paymentHash) + assert(incomingPayment.invoice.nodeId == nodeParams.nodeId) + assert(incomingPayment.invoice.paymentHash == invoice.paymentHash) + assert(maxRecipientPathFees == paymentPayload.amount - amountReceived) + } } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/send/BlindedPathsResolverSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/send/BlindedPathsResolverSpec.scala index 9603b81af3..ab2a0ac59d 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/send/BlindedPathsResolverSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/send/BlindedPathsResolverSpec.scala @@ -114,7 +114,7 @@ class BlindedPathsResolverSpec extends ScalaTestWithActorTestKit(ConfigFactory.l ExtraEdge(nextNodeId, randomKey().publicKey, RealShortChannelId(BlockHeight(700_000), 1, 0), 750_000 msat, 150, CltvExpiryDelta(48), 1 msat, None), ) val hops = edges.map(e => ChannelHop(e.shortChannelId, e.sourceNodeId, e.targetNodeId, HopRelayParams.FromHint(e))) - val route = BlindedRouteCreation.createBlindedRouteFromHops(hops, hex"deadbeef", 1 msat, CltvExpiry(800_000)).route + val route = BlindedRouteCreation.createBlindedRouteFromHops(hops, hops.last.nextNodeId, hex"deadbeef", 1 msat, CltvExpiry(800_000)).route val paymentInfo = BlindedRouteCreation.aggregatePaymentInfo(100_000_000 msat, hops, CltvExpiryDelta(12)) Seq(true, false).foreach { useScidDir => val toResolve = if (useScidDir) { @@ -183,7 +183,7 @@ class BlindedPathsResolverSpec extends ScalaTestWithActorTestKit(ConfigFactory.l val scid = RealShortChannelId(BlockHeight(750_000), 3, 7) val edge = ExtraEdge(nodeParams.nodeId, randomKey().publicKey, scid, 600_000 msat, 100, CltvExpiryDelta(144), 1 msat, None) val hop = ChannelHop(edge.shortChannelId, edge.sourceNodeId, edge.targetNodeId, HopRelayParams.FromHint(edge)) - val route = BlindedRouteCreation.createBlindedRouteFromHops(Seq(hop), hex"deadbeef", 1 msat, CltvExpiry(800_000)).route + val route = BlindedRouteCreation.createBlindedRouteFromHops(Seq(hop), edge.targetNodeId, hex"deadbeef", 1 msat, CltvExpiry(800_000)).route val paymentInfo = BlindedRouteCreation.aggregatePaymentInfo(50_000_000 msat, Seq(hop), CltvExpiryDelta(12)) val toResolve = Seq( PaymentBlindedRoute(route.copy(firstNodeId = EncodedNodeId.ShortChannelIdDir(isNode1 = true, scid)), paymentInfo), @@ -211,15 +211,15 @@ class BlindedPathsResolverSpec extends ScalaTestWithActorTestKit(ConfigFactory.l val edgeLowExpiryDelta = ExtraEdge(nodeParams.nodeId, nextNodeId, scid, 600_000 msat, 100, CltvExpiryDelta(36), 1 msat, None) val toResolve = Seq( // We don't allow paying blinded routes to ourselves. - BlindedRouteCreation.createBlindedRouteWithoutHops(nodeParams.nodeId, hex"deadbeef", 1 msat, CltvExpiry(800_000)).route, + BlindedRouteCreation.createBlindedRouteFromHops(Nil, nodeParams.nodeId, hex"deadbeef", 1 msat, CltvExpiry(800_000)).route, // We reject blinded routes with low fees. - BlindedRouteCreation.createBlindedRouteFromHops(Seq(ChannelHop(scid, nodeParams.nodeId, edgeLowFees.targetNodeId, HopRelayParams.FromHint(edgeLowFees))), hex"deadbeef", 1 msat, CltvExpiry(800_000)).route, + BlindedRouteCreation.createBlindedRouteFromHops(Seq(ChannelHop(scid, nodeParams.nodeId, edgeLowFees.targetNodeId, HopRelayParams.FromHint(edgeLowFees))), edgeLowFees.targetNodeId, hex"deadbeef", 1 msat, CltvExpiry(800_000)).route, // We reject blinded routes with low cltv_expiry_delta. - BlindedRouteCreation.createBlindedRouteFromHops(Seq(ChannelHop(scid, nodeParams.nodeId, edgeLowExpiryDelta.targetNodeId, HopRelayParams.FromHint(edgeLowExpiryDelta))), hex"deadbeef", 1 msat, CltvExpiry(800_000)).route, + BlindedRouteCreation.createBlindedRouteFromHops(Seq(ChannelHop(scid, nodeParams.nodeId, edgeLowExpiryDelta.targetNodeId, HopRelayParams.FromHint(edgeLowExpiryDelta))), edgeLowExpiryDelta.targetNodeId, hex"deadbeef", 1 msat, CltvExpiry(800_000)).route, // We reject blinded routes with low fees, even when the next node seems to be a wallet node. BlindedRouteCreation.createBlindedRouteToWallet(ChannelHop(scid, nodeParams.nodeId, edgeLowFees.targetNodeId, HopRelayParams.FromHint(edgeLowFees)), hex"deadbeef", 1 msat, CltvExpiry(800_000)).route, // We reject blinded routes that cannot be decrypted. - BlindedRouteCreation.createBlindedRouteFromHops(Seq(ChannelHop(scid, nodeParams.nodeId, edgeLowFees.targetNodeId, HopRelayParams.FromHint(edgeLowFees))), hex"deadbeef", 1 msat, CltvExpiry(800_000)).route.copy(firstPathKey = randomKey().publicKey) + BlindedRouteCreation.createBlindedRouteFromHops(Seq(ChannelHop(scid, nodeParams.nodeId, edgeLowFees.targetNodeId, HopRelayParams.FromHint(edgeLowFees))), edgeLowFees.targetNodeId, hex"deadbeef", 1 msat, CltvExpiry(800_000)).route.copy(firstPathKey = randomKey().publicKey) ).map(r => PaymentBlindedRoute(r, PaymentInfo(1_000_000 msat, 2500, CltvExpiryDelta(300), 1 msat, 500_000_000 msat, Features.empty))) resolver ! Resolve(probe.ref, toResolve) // The routes with low fees or expiry require resolving the next node. diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/router/BaseRouterSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/router/BaseRouterSpec.scala index 9d21c490a4..f216c580be 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/router/BaseRouterSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/router/BaseRouterSpec.scala @@ -309,7 +309,7 @@ object BaseRouterSpec { val offer = Offer(None, Some("Bolt12 r0cks"), recipientKey.publicKey, features, Block.RegtestGenesisBlock.hash) val invoiceRequest = InvoiceRequest(offer, amount, 1, features, randomKey(), Block.RegtestGenesisBlock.hash) val blindedRoutes = paths.map(hops => { - val blindedRoute = BlindedRouteCreation.createBlindedRouteFromHops(hops, pathId, 1 msat, routeExpiry).route + val blindedRoute = BlindedRouteCreation.createBlindedRouteFromHops(hops, hops.last.nextNodeId, pathId, 1 msat, routeExpiry).route val paymentInfo = BlindedRouteCreation.aggregatePaymentInfo(amount, hops, Channel.MIN_CLTV_EXPIRY_DELTA) PaymentBlindedRoute(blindedRoute, paymentInfo) }) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/router/BlindedRouteCreationSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/router/BlindedRouteCreationSpec.scala index 55833d5feb..e0cef13f5a 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/router/BlindedRouteCreationSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/router/BlindedRouteCreationSpec.scala @@ -30,7 +30,7 @@ class BlindedRouteCreationSpec extends AnyFunSuite with ParallelTestExecution { test("create blinded route without hops") { val a = randomKey() val pathId = randomBytes32() - val route = createBlindedRouteWithoutHops(a.publicKey, pathId, 1 msat, CltvExpiry(500)) + val route = createBlindedRouteFromHops(Nil, a.publicKey, pathId, 1 msat, CltvExpiry(500)) assert(route.route.firstNodeId == EncodedNodeId(a.publicKey)) assert(route.route.encryptedPayloads.length == 1) assert(route.route.firstPathKey == route.lastPathKey) @@ -47,7 +47,7 @@ class BlindedRouteCreationSpec extends AnyFunSuite with ParallelTestExecution { ChannelHop(scid1, a.publicKey, b.publicKey, HopRelayParams.FromAnnouncement(makeUpdateShort(scid1, a.publicKey, b.publicKey, 10 msat, 300, cltvDelta = CltvExpiryDelta(200)))), ChannelHop(scid2, b.publicKey, c.publicKey, HopRelayParams.FromAnnouncement(makeUpdateShort(scid2, b.publicKey, c.publicKey, 20 msat, 150, cltvDelta = CltvExpiryDelta(600)))), ) - val route = createBlindedRouteFromHops(hops, pathId, 1 msat, CltvExpiry(500)) + val route = createBlindedRouteFromHops(hops, c.publicKey, pathId, 1 msat, CltvExpiry(500)) assert(route.route.firstNodeId == EncodedNodeId(a.publicKey)) assert(route.route.encryptedPayloads.length == 3) val Right(decoded1) = RouteBlindingEncryptedDataCodecs.decode(a, route.route.firstPathKey, route.route.encryptedPayloads(0)) @@ -103,7 +103,7 @@ class BlindedRouteCreationSpec extends AnyFunSuite with ParallelTestExecution { ChannelHop(scid4, d.publicKey, e.publicKey, HopRelayParams.FromAnnouncement(makeUpdateShort(scid4, d.publicKey, e.publicKey, 100000 msat, 100000, cltvDelta = CltvExpiryDelta(60000)))), ChannelHop(scid5, e.publicKey, f.publicKey, HopRelayParams.FromAnnouncement(makeUpdateShort(scid5, e.publicKey, f.publicKey, 999999999 msat, 999999999, cltvDelta = CltvExpiryDelta(65000)))), ) - val route = createBlindedRouteFromHops(hops, randomBytes32(), 0 msat, CltvExpiry(0)) + val route = createBlindedRouteFromHops(hops, f.publicKey, randomBytes32(), 0 msat, CltvExpiry(0)) assert(route.route.encryptedPayloads.dropRight(1).forall(_.length == 54)) }