diff --git a/docs/TrampolinePayments.md b/docs/TrampolinePayments.md deleted file mode 100644 index 90a7f8bd2a..0000000000 --- a/docs/TrampolinePayments.md +++ /dev/null @@ -1,70 +0,0 @@ -# Trampoline Payments - -Eclair started supporting [trampoline payments](https://github.com/lightning/bolts/pull/829) in v0.3.3. - -It is disabled by default, as it is still being reviewed for spec acceptance. However, if you want to experiment with it, here is what you can do. - -First of all, you need to activate the feature for any node that will act as a trampoline node. Update your `eclair.conf` with the following values: - -```conf -eclair.trampoline-payments-enable=true -``` - -## Sending trampoline payments - -The CLI allows you to fully control how your payment is split and sent. This is a good way to start experimenting with Trampoline. - -Let's imagine that the network looks like this: - -```txt -Alice -----> Bob -----> Carol -----> Dave -``` - -Where Bob is a trampoline node and Alice, Carol and Dave are "normal" nodes. - -Let's imagine that Dave has generated an MPP invoice for 400000 msat: `lntb1500n1pwxx94fp...`. -Alice wants to pay that invoice using Bob as a trampoline. -To spice things up, Alice will use MPP between Bob and herself, splitting the payment in two parts. - -Initiate the payment by sending the first part: - -```sh -eclair-cli sendtoroute --amountMsat=150000 --nodeIds=$ALICE_ID,$BOB_ID --trampolineFeesMsat=10000 --trampolineCltvExpiry=450 --finalCltvExpiry=16 --invoice=lntb1500n1pwxx94fp... -``` - -Note the `trampolineFeesMsat` and `trampolineCltvExpiry`. At the moment you have to estimate those yourself. If the values you provide are too low, Bob will send an error and you can retry with higher values. In future versions, we will automatically fill those values for you. - -The command will return some identifiers that must be used for the other parts: - -```json -{ - "paymentId": "4e8f2440-dbfd-4e76-bb45-a0647a966b2a", - "parentId": "cd083b31-5939-46ac-bf90-8ac5b286a9e2", - "trampolineSecret": "9e13d1b602496871bb647b48e8ff8f15a91c07affb0a3599e995d470ac488715" -} -``` - -The `parentId` is important: this is the identifier used to link the MPP parts together. - -The `trampolineSecret` is also important: this is what prevents a malicious trampoline node from stealing money. - -Now that you have those, you can send the second part: - -```sh -eclair-cli sendtoroute --amountMsat=260000 --parentId=cd083b31-5939-46ac-bf90-8ac5b286a9e2 --trampolineSecret=9e13d1b602496871bb647b48e8ff8f15a91c07affb0a3599e995d470ac488715 --nodeIds=$ALICE_ID,$BOB_ID --trampolineFeesMsat=10000 --trampolineCltvExpiry=450 --finalCltvExpiry=16 --invoice=lntb1500n1pwxx94fp... -``` - -Note that Alice didn't need to know about Carol. Bob will find the route to Dave through Carol on his own. That's the magic of trampoline! - -A couple gotchas: - -- you need to make sure you specify the same `trampolineFeesMsat` and `trampolineCltvExpiry` as the first part -- the total `amountMsat` sent need to cover the `trampolineFeesMsat` specified - -You can then check the status of the payment with the `getsentinfo` command: - -```sh -eclair-cli getsentinfo --id=cd083b31-5939-46ac-bf90-8ac5b286a9e2 -``` - -Once Dave accepts the payment you should see all the details about the payment success (preimage, route, fees, etc). diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala index 60be7ba39a..48366270e1 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala @@ -142,7 +142,7 @@ trait Eclair { def findRouteBetween(sourceNodeId: PublicKey, targetNodeId: PublicKey, amount: MilliSatoshi, pathFindingExperimentName_opt: Option[String], extraEdges: Seq[Invoice.ExtraEdge] = Seq.empty, includeLocalChannelCost: Boolean = false, ignoreNodeIds: Seq[PublicKey] = Seq.empty, ignoreShortChannelIds: Seq[ShortChannelId] = Seq.empty, maxFee_opt: Option[MilliSatoshi] = None)(implicit timeout: Timeout): Future[RouteResponse] - def sendToRoute(recipientAmount_opt: Option[MilliSatoshi], externalId_opt: Option[String], parentId_opt: Option[UUID], invoice: Bolt11Invoice, route: PredefinedRoute, trampolineSecret_opt: Option[ByteVector32] = None, trampolineFees_opt: Option[MilliSatoshi] = None, trampolineExpiryDelta_opt: Option[CltvExpiryDelta] = None)(implicit timeout: Timeout): Future[SendPaymentToRouteResponse] + def sendToRoute(recipientAmount_opt: Option[MilliSatoshi], externalId_opt: Option[String], parentId_opt: Option[UUID], invoice: Bolt11Invoice, route: PredefinedRoute)(implicit timeout: Timeout): Future[SendPaymentToRouteResponse] def audit(from: TimestampSecond, to: TimestampSecond, paginated_opt: Option[Paginated])(implicit timeout: Timeout): Future[AuditResponse] @@ -184,7 +184,7 @@ trait Eclair { def payOfferBlocking(offer: Offer, amount: MilliSatoshi, quantity: Long, externalId_opt: Option[String] = None, maxAttempts_opt: Option[Int] = None, maxFeeFlat_opt: Option[Satoshi] = None, maxFeePct_opt: Option[Double] = None, pathFindingExperimentName_opt: Option[String] = None, connectDirectly: Boolean = false)(implicit timeout: Timeout): Future[PaymentEvent] - def payOfferTrampoline(offer: Offer, amount: MilliSatoshi, quantity: Long, trampolineNodeId: PublicKey, trampolineAttempts: Seq[(MilliSatoshi, CltvExpiryDelta)], externalId_opt: Option[String] = None, maxAttempts_opt: Option[Int] = None, maxFeeFlat_opt: Option[Satoshi] = None, maxFeePct_opt: Option[Double] = None, pathFindingExperimentName_opt: Option[String] = None, connectDirectly: Boolean = false)(implicit timeout: Timeout): Future[UUID] + def payOfferTrampoline(offer: Offer, amount: MilliSatoshi, quantity: Long, trampolineNodeId: PublicKey, externalId_opt: Option[String] = None, maxAttempts_opt: Option[Int] = None, maxFeeFlat_opt: Option[Satoshi] = None, maxFeePct_opt: Option[Double] = None, pathFindingExperimentName_opt: Option[String] = None, connectDirectly: Boolean = false)(implicit timeout: Timeout): Future[PaymentEvent] def getOnChainMasterPubKey(account: Long): String @@ -449,19 +449,16 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging { } } - override def sendToRoute(recipientAmount_opt: Option[MilliSatoshi], externalId_opt: Option[String], parentId_opt: Option[UUID], invoice: Bolt11Invoice, route: PredefinedRoute, trampolineSecret_opt: Option[ByteVector32], trampolineFees_opt: Option[MilliSatoshi], trampolineExpiryDelta_opt: Option[CltvExpiryDelta])(implicit timeout: Timeout): Future[SendPaymentToRouteResponse] = { + override def sendToRoute(recipientAmount_opt: Option[MilliSatoshi], externalId_opt: Option[String], parentId_opt: Option[UUID], invoice: Bolt11Invoice, route: PredefinedRoute)(implicit timeout: Timeout): Future[SendPaymentToRouteResponse] = { if (invoice.isExpired()) { Future.failed(new IllegalArgumentException("invoice has expired")) } else if (route.isEmpty) { Future.failed(new IllegalArgumentException("missing payment route")) } else if (externalId_opt.exists(_.length > externalIdMaxLength)) { Future.failed(new IllegalArgumentException(s"externalId is too long: cannot exceed $externalIdMaxLength characters")) - } else if (trampolineFees_opt.nonEmpty && trampolineExpiryDelta_opt.isEmpty) { - Future.failed(new IllegalArgumentException("trampoline payments must specify a trampoline fee and cltv delta")) } else { val recipientAmount = recipientAmount_opt.getOrElse(invoice.amount_opt.getOrElse(route.amount)) - val trampoline_opt = trampolineFees_opt.map(fees => TrampolineAttempt(trampolineSecret_opt.getOrElse(randomBytes32()), fees, trampolineExpiryDelta_opt.get)) - val sendPayment = SendPaymentToRoute(recipientAmount, invoice, Nil, route, externalId_opt, parentId_opt, trampoline_opt) + val sendPayment = SendPaymentToRoute(recipientAmount, invoice, Nil, route, externalId_opt, parentId_opt) (appKit.paymentInitiator ? sendPayment).mapTo[SendPaymentToRouteResponse] } } @@ -529,7 +526,7 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging { case PendingSpontaneousPayment(_, r) => OutgoingPayment(paymentId, paymentId, r.externalId, paymentHash, paymentType, r.recipientAmount, r.recipientAmount, r.recipientNodeId, TimestampMilli.now(), None, None, OutgoingPaymentStatus.Pending) case PendingPaymentToNode(_, r) => OutgoingPayment(paymentId, paymentId, r.externalId, paymentHash, paymentType, r.recipientAmount, r.recipientAmount, r.recipientNodeId, TimestampMilli.now(), Some(r.invoice), r.payerKey_opt, OutgoingPaymentStatus.Pending) case PendingPaymentToRoute(_, r) => OutgoingPayment(paymentId, paymentId, r.externalId, paymentHash, paymentType, r.recipientAmount, r.recipientAmount, r.recipientNodeId, TimestampMilli.now(), Some(r.invoice), None, OutgoingPaymentStatus.Pending) - case PendingTrampolinePayment(_, _, r) => OutgoingPayment(paymentId, paymentId, None, paymentHash, paymentType, r.recipientAmount, r.recipientAmount, r.recipientNodeId, TimestampMilli.now(), Some(r.invoice), None, OutgoingPaymentStatus.Pending) + case PendingTrampolinePayment(_, r) => OutgoingPayment(paymentId, paymentId, None, paymentHash, paymentType, r.recipientAmount, r.recipientAmount, r.recipientNodeId, TimestampMilli.now(), Some(r.invoice), None, OutgoingPaymentStatus.Pending) } dummyOutgoingPayment +: outgoingDbPayments } @@ -719,7 +716,6 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging { amount: MilliSatoshi, quantity: Long, trampolineNodeId_opt: Option[PublicKey], - trampolineAttempts: Seq[(MilliSatoshi, CltvExpiryDelta)], externalId_opt: Option[String], maxAttempts_opt: Option[Int], maxFeeFlat_opt: Option[Satoshi], @@ -737,8 +733,7 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging { .modify(_.boundaries.maxFeeFlat).setToIfDefined(maxFeeFlat_opt.map(_.toMilliSatoshi)) case Left(t) => return Future.failed(t) } - val trampoline = trampolineNodeId_opt.map(trampolineNodeId => OfferPayment.TrampolineConfig(trampolineNodeId, trampolineAttempts)) - val sendPaymentConfig = OfferPayment.SendPaymentConfig(externalId_opt, connectDirectly, maxAttempts_opt.getOrElse(appKit.nodeParams.maxPaymentAttempts), routeParams, blocking, trampoline) + val sendPaymentConfig = OfferPayment.SendPaymentConfig(externalId_opt, connectDirectly, maxAttempts_opt.getOrElse(appKit.nodeParams.maxPaymentAttempts), routeParams, blocking, trampolineNodeId_opt) val offerPayment = appKit.system.spawnAnonymous(OfferPayment(appKit.nodeParams, appKit.postman, appKit.router, appKit.register, appKit.paymentInitiator)) offerPayment.ask((ref: typed.ActorRef[Any]) => OfferPayment.PayOffer(ref.toClassic, offer, amount, quantity, sendPaymentConfig)).flatMap { case f: OfferPayment.Failure => Future.failed(new Exception(f.toString)) @@ -755,7 +750,7 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging { maxFeePct_opt: Option[Double], pathFindingExperimentName_opt: Option[String], connectDirectly: Boolean)(implicit timeout: Timeout): Future[UUID] = { - payOfferInternal(offer, amount, quantity, None, Nil, externalId_opt, maxAttempts_opt, maxFeeFlat_opt, maxFeePct_opt, pathFindingExperimentName_opt, connectDirectly, blocking = false).mapTo[UUID] + payOfferInternal(offer, amount, quantity, None, externalId_opt, maxAttempts_opt, maxFeeFlat_opt, maxFeePct_opt, pathFindingExperimentName_opt, connectDirectly, blocking = false).mapTo[UUID] } override def payOfferBlocking(offer: Offer, @@ -767,21 +762,20 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging { maxFeePct_opt: Option[Double], pathFindingExperimentName_opt: Option[String], connectDirectly: Boolean)(implicit timeout: Timeout): Future[PaymentEvent] = { - payOfferInternal(offer, amount, quantity, None, Nil, externalId_opt, maxAttempts_opt, maxFeeFlat_opt, maxFeePct_opt, pathFindingExperimentName_opt, connectDirectly, blocking = true).mapTo[PaymentEvent] + payOfferInternal(offer, amount, quantity, None, externalId_opt, maxAttempts_opt, maxFeeFlat_opt, maxFeePct_opt, pathFindingExperimentName_opt, connectDirectly, blocking = true).mapTo[PaymentEvent] } override def payOfferTrampoline(offer: Offer, amount: MilliSatoshi, quantity: Long, trampolineNodeId: PublicKey, - trampolineAttempts: Seq[(MilliSatoshi, CltvExpiryDelta)], externalId_opt: Option[String], maxAttempts_opt: Option[Int], maxFeeFlat_opt: Option[Satoshi], maxFeePct_opt: Option[Double], pathFindingExperimentName_opt: Option[String], - connectDirectly: Boolean)(implicit timeout: Timeout): Future[UUID] = { - payOfferInternal(offer, amount, quantity, Some(trampolineNodeId), trampolineAttempts, externalId_opt, maxAttempts_opt, maxFeeFlat_opt, maxFeePct_opt, pathFindingExperimentName_opt, connectDirectly, blocking = false).mapTo[UUID] + connectDirectly: Boolean)(implicit timeout: Timeout): Future[PaymentEvent] = { + payOfferInternal(offer, amount, quantity, Some(trampolineNodeId), externalId_opt, maxAttempts_opt, maxFeeFlat_opt, maxFeePct_opt, pathFindingExperimentName_opt, connectDirectly, blocking = true).mapTo[PaymentEvent] } override def getDescriptors(account: Long): Descriptors = appKit.nodeParams.onChainKeyManager_opt match { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentEvents.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentEvents.scala index 030d5de45e..34972e5ea5 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentEvents.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentEvents.scala @@ -56,9 +56,7 @@ sealed trait PaymentEvent { case class PaymentSent(id: UUID, paymentHash: ByteVector32, paymentPreimage: ByteVector32, recipientAmount: MilliSatoshi, recipientNodeId: PublicKey, parts: Seq[PaymentSent.PartialPayment]) extends PaymentEvent { require(parts.nonEmpty, "must have at least one payment part") val amountWithFees: MilliSatoshi = parts.map(_.amountWithFees).sum - val feesPaid: MilliSatoshi = amountWithFees - recipientAmount // overall fees for this payment (routing + trampoline) - val trampolineFees: MilliSatoshi = parts.map(_.amount).sum - recipientAmount - val nonTrampolineFees: MilliSatoshi = feesPaid - trampolineFees // routing fees to reach the first trampoline node, or the recipient if not using trampoline + val feesPaid: MilliSatoshi = amountWithFees - recipientAmount // overall fees for this payment val timestamp: TimestampMilli = parts.map(_.timestamp).min // we use min here because we receive the proof of payment as soon as the first partial payment is fulfilled } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/NodeRelay.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/NodeRelay.scala index d546ff4e06..ad252b669d 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/NodeRelay.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/NodeRelay.scala @@ -138,9 +138,8 @@ object NodeRelay { /** This function identifies whether the next node is a wallet node directly connected to us, and returns its node_id. */ private def nextWalletNodeId(nodeParams: NodeParams, recipient: Recipient): Option[PublicKey] = { recipient match { - // These two recipients are only used when we're the payment initiator. + // This recipient is only used when we're the payment initiator. case _: SpontaneousRecipient => None - case _: TrampolineRecipient => None // When relaying to a trampoline node, the next node may be a wallet node directly connected to us, but we don't // want to have false positives. Feature branches should check an internal DB/cache to confirm. case r: ClearRecipient if r.nextTrampolineOnion_opt.nonEmpty => None @@ -406,7 +405,6 @@ class NodeRelay private(nodeParams: NodeParams, val finalHop_opt = recipient match { case _: ClearRecipient => None case _: SpontaneousRecipient => None - case _: TrampolineRecipient => None case r: BlindedRecipient => r.blindedHops.headOption } val dummyRoute = Route(nextPayload.amountToForward, Seq(dummyHop), finalHop_opt) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/OfferPayment.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/OfferPayment.scala index c3a302867c..2ed7ac0b5c 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/OfferPayment.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/OfferPayment.scala @@ -31,7 +31,7 @@ import fr.acinq.eclair.router.Router.RouteParams import fr.acinq.eclair.wire.protocol.MessageOnion.{FinalPayload, InvoicePayload} import fr.acinq.eclair.wire.protocol.OfferTypes._ import fr.acinq.eclair.wire.protocol.{OnionMessagePayloadTlv, TlvStream} -import fr.acinq.eclair.{CltvExpiryDelta, EncodedNodeId, Features, InvoiceFeature, MilliSatoshi, NodeParams, RealShortChannelId, TimestampSecond, randomKey} +import fr.acinq.eclair.{EncodedNodeId, Features, InvoiceFeature, MilliSatoshi, NodeParams, RealShortChannelId, TimestampSecond, randomKey} object OfferPayment { // @formatter:off @@ -62,9 +62,7 @@ object OfferPayment { maxAttempts: Int, routeParams: RouteParams, blocking: Boolean, - trampoline: Option[TrampolineConfig] = None) - - case class TrampolineConfig(nodeId: PublicKey, attempts: Seq[(MilliSatoshi, CltvExpiryDelta)]) + trampolineNodeId_opt: Option[PublicKey] = None) def apply(nodeParams: NodeParams, postman: typed.ActorRef[Postman.Command], @@ -123,9 +121,9 @@ private class OfferPayment(replyTo: ActorRef, private def waitForInvoice(attemptNumber: Int, pathNodeId: PublicKey): Behavior[Command] = { Behaviors.receiveMessagePartial { case WrappedMessageResponse(Postman.Response(payload: InvoicePayload)) if payload.invoice.validateFor(invoiceRequest, pathNodeId).isRight => - sendPaymentConfig.trampoline match { - case Some(trampoline) => - paymentInitiator ! SendTrampolinePayment(replyTo, payload.invoice.amount, payload.invoice, trampoline.nodeId, trampoline.attempts, sendPaymentConfig.routeParams) + sendPaymentConfig.trampolineNodeId_opt match { + case Some(trampolineNodeId) => + paymentInitiator ! SendTrampolinePayment(replyTo, payload.invoice, trampolineNodeId, sendPaymentConfig.routeParams, sendPaymentConfig.blocking) Behaviors.stopped case None => context.spawnAnonymous(BlindedPathsResolver(nodeParams, payload.invoice.paymentHash, router, register)) ! Resolve(context.messageAdapter[Seq[ResolvedPath]](WrappedResolvedPaths), payload.invoice.blindedPaths) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/PaymentError.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/PaymentError.scala index 8ac3ed8bb8..b0f4d902fa 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/PaymentError.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/PaymentError.scala @@ -28,16 +28,6 @@ object PaymentError { case class UnsupportedFeatures(features: Features[InvoiceFeature]) extends InvalidInvoice { override def getMessage: String = s"unsupported invoice features: ${features.toByteVector.toHex}" } // @formatter:on - // @formatter:off - sealed trait InvalidTrampolineArguments extends PaymentError - /** Trampoline fees or cltv expiry delta is missing. */ - case object TrampolineFeesMissing extends InvalidTrampolineArguments { override def getMessage: String = "cannot send payment: trampoline fees missing" } - /** 0-value invoice should not be paid via trampoline-to-legacy (trampoline may steal funds). */ - case object TrampolineLegacyAmountLessInvoice extends InvalidTrampolineArguments { override def getMessage: String = "cannot send payment: unsafe trampoline-to-legacy amount-less invoice" } - /** Only a single trampoline node is currently supported. */ - case object TrampolineMultiNodeNotSupported extends InvalidTrampolineArguments { override def getMessage: String = "cannot send payment: multiple trampoline hops not supported" } - // @formatter:on - // @formatter:off /** Payment attempts exhausted without success. */ case object RetryExhausted extends PaymentError { override def getMessage: String = "payment attempts exhausted without success" } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/PaymentInitiator.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/PaymentInitiator.scala index da7950ac62..d691d8c5ff 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/PaymentInitiator.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/PaymentInitiator.scala @@ -16,20 +16,19 @@ package fr.acinq.eclair.payment.send -import akka.actor.{Actor, ActorContext, ActorLogging, ActorRef, Props} +import akka.actor.typed.scaladsl.adapter._ +import akka.actor.{Actor, ActorContext, ActorLogging, ActorRef, Props, typed} import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} import fr.acinq.bitcoin.scalacompat.{ByteVector32, Crypto} import fr.acinq.eclair.channel.Upstream import fr.acinq.eclair.channel.fsm.Channel -import fr.acinq.eclair.crypto.Sphinx import fr.acinq.eclair.db.PaymentType import fr.acinq.eclair.payment._ import fr.acinq.eclair.payment.send.BlindedPathsResolver.ResolvedPath import fr.acinq.eclair.payment.send.PaymentError._ -import fr.acinq.eclair.router.RouteNotFound import fr.acinq.eclair.router.Router._ import fr.acinq.eclair.wire.protocol._ -import fr.acinq.eclair.{CltvExpiry, CltvExpiryDelta, Features, MilliSatoshi, NodeParams, randomBytes32} +import fr.acinq.eclair.{CltvExpiry, CltvExpiryDelta, Features, MilliSatoshi, MilliSatoshiLong, NodeParams} import java.util.UUID @@ -80,89 +79,45 @@ class PaymentInitiator(nodeParams: NodeParams, outgoingPaymentFactory: PaymentIn case r: SendTrampolinePayment => val paymentId = UUID.randomUUID() - r.replyTo ! paymentId - r.trampolineAttempts match { - case Nil => - r.replyTo ! PaymentFailed(paymentId, r.paymentHash, LocalFailure(r.recipientAmount, Nil, TrampolineFeesMissing) :: Nil) - case _ if !r.invoice.features.hasFeature(Features.TrampolinePaymentPrototype) && r.invoice.amount_opt.isEmpty => - r.replyTo ! PaymentFailed(paymentId, r.paymentHash, LocalFailure(r.recipientAmount, Nil, TrampolineLegacyAmountLessInvoice) :: Nil) - case (trampolineFees, trampolineExpiryDelta) :: remainingAttempts => - log.info(s"sending trampoline payment with trampoline fees=$trampolineFees and expiry delta=$trampolineExpiryDelta") - sendTrampolinePayment(paymentId, r, trampolineFees, trampolineExpiryDelta) - context become main(pending + (paymentId -> PendingTrampolinePayment(r.replyTo, remainingAttempts, r))) + if (!r.blockUntilComplete) { + r.replyTo ! paymentId + } + if (r.invoice.amount_opt.isEmpty) { + r.replyTo ! PaymentFailed(paymentId, r.paymentHash, LocalFailure(r.recipientAmount, Nil, new IllegalArgumentException("test trampoline payments must not use amount-less invoices")) :: Nil) + } else { + log.info(s"sending trampoline payment with trampolineNodeId=${r.trampolineNodeId} and invoice=${r.invoice.toString}") + val fsm = outgoingPaymentFactory.spawnOutgoingTrampolinePayment(context) + fsm ! TrampolinePaymentLifecycle.SendPayment(self, paymentId, r.trampolineNodeId, r.invoice, r.routeParams) + context become main(pending + (paymentId -> PendingTrampolinePayment(r.replyTo, r))) } case r: SendPaymentToRoute => val paymentId = UUID.randomUUID() val parentPaymentId = r.parentId.getOrElse(UUID.randomUUID()) - r.trampoline_opt match { - case _ if !nodeParams.features.invoiceFeatures().areSupported(r.invoice.features) => - sender() ! PaymentFailed(paymentId, r.paymentHash, LocalFailure(r.recipientAmount, Nil, UnsupportedFeatures(r.invoice.features)) :: Nil) - case Some(trampolineAttempt) => - val trampolineNodeId = r.route.targetNodeId - log.info(s"sending trampoline payment to ${r.recipientNodeId} with trampoline=$trampolineNodeId, trampoline fees=${trampolineAttempt.fees}, expiry delta=${trampolineAttempt.cltvExpiryDelta}") - val trampolineHop = NodeHop(trampolineNodeId, r.recipientNodeId, trampolineAttempt.cltvExpiryDelta, trampolineAttempt.fees) - val recipient = buildTrampolineRecipient(r, trampolineHop) - sender() ! SendPaymentToRouteResponse(paymentId, parentPaymentId, Some(recipient.trampolinePaymentSecret)) - val paymentCfg = SendPaymentConfig(paymentId, parentPaymentId, r.externalId, r.paymentHash, r.recipientNodeId, Upstream.Local(paymentId), Some(r.invoice), None, storeInDb = true, publishEvent = true, recordPathFindingMetrics = false, confidence = 1.0) - val payFsm = outgoingPaymentFactory.spawnOutgoingPayment(context, paymentCfg) - payFsm ! PaymentLifecycle.SendPaymentToRoute(self, Left(r.route), recipient) - context become main(pending + (paymentId -> PendingPaymentToRoute(sender(), r))) - case None => - sender() ! SendPaymentToRouteResponse(paymentId, parentPaymentId, None) - val paymentCfg = SendPaymentConfig(paymentId, parentPaymentId, r.externalId, r.paymentHash, r.recipientNodeId, Upstream.Local(paymentId), Some(r.invoice), None, storeInDb = true, publishEvent = true, recordPathFindingMetrics = false, confidence = 1.0) - val finalExpiry = r.finalExpiry(nodeParams) - val recipient = r.invoice match { - case invoice: Bolt11Invoice => ClearRecipient(invoice, r.recipientAmount, finalExpiry, Set.empty) - case invoice: Bolt12Invoice => BlindedRecipient(invoice, r.resolvedPaths, r.recipientAmount, finalExpiry, Set.empty) - } - val payFsm = outgoingPaymentFactory.spawnOutgoingPayment(context, paymentCfg) - payFsm ! PaymentLifecycle.SendPaymentToRoute(self, Left(r.route), recipient) - context become main(pending + (paymentId -> PendingPaymentToRoute(sender(), r))) - case _ => - sender() ! PaymentFailed(paymentId, r.paymentHash, LocalFailure(r.recipientAmount, Nil, TrampolineMultiNodeNotSupported) :: Nil) + if (!nodeParams.features.invoiceFeatures().areSupported(r.invoice.features)) { + sender() ! PaymentFailed(paymentId, r.paymentHash, LocalFailure(r.recipientAmount, Nil, UnsupportedFeatures(r.invoice.features)) :: Nil) + } else { + sender() ! SendPaymentToRouteResponse(paymentId, parentPaymentId) + val paymentCfg = SendPaymentConfig(paymentId, parentPaymentId, r.externalId, r.paymentHash, r.recipientNodeId, Upstream.Local(paymentId), Some(r.invoice), None, storeInDb = true, publishEvent = true, recordPathFindingMetrics = false, confidence = 1.0) + val finalExpiry = r.finalExpiry(nodeParams) + val recipient = r.invoice match { + case invoice: Bolt11Invoice => ClearRecipient(invoice, r.recipientAmount, finalExpiry, Set.empty) + case invoice: Bolt12Invoice => BlindedRecipient(invoice, r.resolvedPaths, r.recipientAmount, finalExpiry, Set.empty) + } + val payFsm = outgoingPaymentFactory.spawnOutgoingPayment(context, paymentCfg) + payFsm ! PaymentLifecycle.SendPaymentToRoute(self, Left(r.route), recipient) + context become main(pending + (paymentId -> PendingPaymentToRoute(sender(), r))) } - case pf: PaymentFailed => pending.get(pf.id).foreach { - case pp: PendingTrampolinePayment => - val trampolineHop = NodeHop(pp.r.trampolineNodeId, pp.r.recipientNodeId, pp.r.trampolineAttempts.last._2, pp.r.trampolineAttempts.last._1) - val decryptedFailures = pf.failures.collect { case RemoteFailure(_, _, Sphinx.DecryptedFailurePacket(_, f)) => f } - val shouldRetry = decryptedFailures.exists { - case _: TrampolineFeeInsufficient => true - case _: TrampolineExpiryTooSoon => true - case _ => false - } - if (shouldRetry) { - pp.remainingAttempts match { - case (trampolineFees, trampolineExpiryDelta) :: remaining => - log.info(s"retrying trampoline payment with trampoline fees=$trampolineFees and expiry delta=$trampolineExpiryDelta") - sendTrampolinePayment(pf.id, pp.r, trampolineFees, trampolineExpiryDelta) - context become main(pending + (pf.id -> pp.copy(remainingAttempts = remaining))) - case Nil => - log.info("trampoline node couldn't find a route after all retries") - val localFailure = pf.copy(failures = Seq(LocalFailure(pp.r.recipientAmount, Seq(trampolineHop), RouteNotFound))) - pp.sender ! localFailure - context.system.eventStream.publish(localFailure) - context become main(pending - pf.id) - } - } else { - pp.sender ! pf - context.system.eventStream.publish(pf) - context become main(pending - pf.id) - } - case pp => - pp.sender ! pf - context become main(pending - pf.id) + case pf: PaymentFailed => pending.get(pf.id).foreach { pp => + pp.sender ! pf + context become main(pending - pf.id) } - case ps: PaymentSent => pending.get(ps.id).foreach(pp => { + case ps: PaymentSent => pending.get(ps.id).foreach { pp => pp.sender ! ps - pp match { - case _: PendingTrampolinePayment => context.system.eventStream.publish(ps) - case _ => // other types of payment internally handle publishing the event - } context become main(pending - ps.id) - }) + } case GetPayment(id) => val pending_opt = id match { @@ -180,24 +135,6 @@ class PaymentInitiator(nodeParams: NodeParams, outgoingPaymentFactory: PaymentIn } - private def buildTrampolineRecipient(r: SendRequestedPayment, trampolineHop: NodeHop): TrampolineRecipient = { - // We generate a random secret for the payment to the trampoline node. - val trampolineSecret = r match { - case r: SendPaymentToRoute => r.trampoline_opt.map(_.paymentSecret).getOrElse(randomBytes32()) - case _ => randomBytes32() - } - val finalExpiry = r.finalExpiry(nodeParams) - TrampolineRecipient(r.invoice, r.recipientAmount, finalExpiry, trampolineHop, trampolineSecret) - } - - private def sendTrampolinePayment(paymentId: UUID, r: SendTrampolinePayment, trampolineFees: MilliSatoshi, trampolineExpiryDelta: CltvExpiryDelta): Unit = { - val trampolineHop = NodeHop(r.trampolineNodeId, r.recipientNodeId, trampolineExpiryDelta, trampolineFees) - val paymentCfg = SendPaymentConfig(paymentId, paymentId, None, r.paymentHash, r.recipientNodeId, Upstream.Local(paymentId), Some(r.invoice), None, storeInDb = true, publishEvent = false, recordPathFindingMetrics = true, confidence = 1.0) - val recipient = buildTrampolineRecipient(r, trampolineHop) - val fsm = outgoingPaymentFactory.spawnOutgoingMultiPartPayment(context, paymentCfg, publishPreimage = false) - fsm ! MultiPartPaymentLifecycle.SendMultiPartPayment(self, recipient, nodeParams.maxPaymentAttempts, r.routeParams) - } - } // @formatter:off @@ -215,7 +152,11 @@ object PaymentInitiator { def spawnOutgoingPayment(context: ActorContext, cfg: SendPaymentConfig): ActorRef } - trait MultiPartPaymentFactory extends PaymentFactory { + trait TrampolinePaymentFactory { + def spawnOutgoingTrampolinePayment(context: ActorContext): typed.ActorRef[TrampolinePaymentLifecycle.Command] + } + + trait MultiPartPaymentFactory extends PaymentFactory with TrampolinePaymentFactory { def spawnOutgoingMultiPartPayment(context: ActorContext, cfg: SendPaymentConfig, publishPreimage: Boolean): ActorRef } @@ -227,6 +168,10 @@ object PaymentInitiator { override def spawnOutgoingMultiPartPayment(context: ActorContext, cfg: SendPaymentConfig, publishPreimage: Boolean): ActorRef = { context.actorOf(MultiPartPaymentLifecycle.props(nodeParams, cfg, publishPreimage, router, this)) } + + override def spawnOutgoingTrampolinePayment(context: ActorContext): typed.ActorRef[TrampolinePaymentLifecycle.Command] = { + context.spawnAnonymous(TrampolinePaymentLifecycle(nodeParams, register.toTyped)) + } } def props(nodeParams: NodeParams, outgoingPaymentFactory: MultiPartPaymentFactory) = Props(new PaymentInitiator(nodeParams, outgoingPaymentFactory)) @@ -239,7 +184,7 @@ object PaymentInitiator { case class PendingSpontaneousPayment(sender: ActorRef, request: SendSpontaneousPayment) extends PendingPayment { override def paymentHash: ByteVector32 = request.paymentHash } case class PendingPaymentToNode(sender: ActorRef, request: SendPaymentToNode) extends PendingPayment { override def paymentHash: ByteVector32 = request.paymentHash } case class PendingPaymentToRoute(sender: ActorRef, request: SendPaymentToRoute) extends PendingPayment { override def paymentHash: ByteVector32 = request.paymentHash } - case class PendingTrampolinePayment(sender: ActorRef, remainingAttempts: Seq[(MilliSatoshi, CltvExpiryDelta)], r: SendTrampolinePayment) extends PendingPayment { override def paymentHash: ByteVector32 = r.paymentHash } + case class PendingTrampolinePayment(sender: ActorRef, request: SendTrampolinePayment) extends PendingPayment { override def paymentHash: ByteVector32 = request.paymentHash } // @formatter:on // @formatter:off @@ -267,23 +212,22 @@ object PaymentInitiator { } /** - * This command should be used to test the trampoline implementation until the feature is fully specified. + * Eclair nodes never need to send trampoline payments, but they need to be able to relay them or receive them. + * This command is only used in e2e tests, to simulate the behavior of a trampoline sender and verify that relaying + * and receiving payments work. * - * @param recipientAmount amount that should be received by the final recipient (usually from a Bolt 11 invoice). * @param invoice Bolt 11 invoice. - * @param trampolineNodeId id of the trampoline node. - * @param trampolineAttempts fees and expiry delta for the trampoline node. If this list contains multiple entries, - * the payment will automatically be retried in case of TrampolineFeeInsufficient errors. - * For example, [(10 msat, 144), (15 msat, 288)] will first send a payment with a fee of 10 - * msat and cltv of 144, and retry with 15 msat and 288 in case an error occurs. - * @param routeParams (optional) parameters to fine-tune the routing algorithm. + * @param trampolineNodeId id of the trampoline node (which must be a direct peer for simplicity). + * @param routeParams (optional) parameters to fine-tune the maximum fee allowed. + * @param blockUntilComplete (optional) if true, wait until the payment completes before returning a result. */ case class SendTrampolinePayment(replyTo: ActorRef, - recipientAmount: MilliSatoshi, invoice: Invoice, trampolineNodeId: PublicKey, - trampolineAttempts: Seq[(MilliSatoshi, CltvExpiryDelta)], - routeParams: RouteParams) extends SendRequestedPayment + routeParams: RouteParams, + blockUntilComplete: Boolean = false) extends SendRequestedPayment { + override val recipientAmount = invoice.amount_opt.getOrElse(0 msat) + } /** * @param recipientAmount amount that should be received by the final recipient (usually from a Bolt 11 invoice). @@ -328,56 +272,32 @@ object PaymentInitiator { val paymentHash = Crypto.sha256(paymentPreimage) } - /** - * @param paymentSecret this is a secret to protect the payment to the trampoline node against probing. - * @param fees fees for the trampoline node. - * @param cltvExpiryDelta expiry delta for the trampoline node. - */ - case class TrampolineAttempt(paymentSecret: ByteVector32, fees: MilliSatoshi, cltvExpiryDelta: CltvExpiryDelta) - /** * The sender can skip the routing algorithm by specifying the route to use. * - * When combining with MPP and Trampoline, extra-care must be taken to make sure payments are correctly grouped: only - * amount, route and trampoline_opt should be changing. Splitting across multiple trampoline nodes isn't supported. - * - * Example 1: MPP containing two HTLCs for a 600 msat invoice: - * SendPaymentToRoute(600 msat, invoice, Route(200 msat, Seq(alice, bob, dave)), None, Some(parentId), None) - * SendPaymentToRoute(600 msat, invoice, Route(400 msat, Seq(alice, carol, dave)), None, Some(parentId), None) - * - * Example 2: Trampoline with MPP for a 600 msat invoice and 100 msat trampoline fees: - * SendPaymentToRoute(600 msat, invoice, Route(250 msat, Seq(alice, bob, ted)), None, Some(parentId), Some(TrampolineAttempt(secret, 100 msat, CltvExpiryDelta(144)))) - * SendPaymentToRoute(600 msat, invoice, Route(450 msat, Seq(alice, carol, ted)), None, Some(parentId), Some(TrampolineAttempt(secret, 100 msat, CltvExpiryDelta(144)))) - * * @param recipientAmount amount that should be received by the final recipient (usually from a Bolt 11 invoice). * This amount may be split between multiple requests if using MPP. * @param invoice Bolt 11 invoice. * @param resolvedPaths when using a Bolt 12 invoice, list of payment paths to reach the recipient. - * @param route route to use to reach either the final recipient or the trampoline node. + * @param route route to use to reach the final recipient. * @param externalId (optional) externally-controlled identifier (to reconcile between application DB and eclair DB). * @param parentId id of the whole payment. When manually sending a multi-part payment, you need to make * sure all partial payments use the same parentId. If not provided, a random parentId will * be generated that can be used for the remaining partial payments. - * @param trampoline_opt if trampoline is used, this field must be provided. When manually sending a multi-part - * payment, you need to make sure all partial payments share the same values. */ case class SendPaymentToRoute(recipientAmount: MilliSatoshi, invoice: Invoice, resolvedPaths: Seq[ResolvedPath], route: PredefinedRoute, externalId: Option[String], - parentId: Option[UUID], - trampoline_opt: Option[TrampolineAttempt]) extends SendRequestedPayment + parentId: Option[UUID]) extends SendRequestedPayment /** - * @param paymentId id of the outgoing payment (mapped to a single outgoing HTLC). - * @param parentId id of the whole payment. When manually sending a multi-part payment, you need to make sure - * all partial payments use the same parentId. - * @param trampolineSecret if trampoline is used, this is a secret to protect the payment to the first trampoline node - * against probing. When manually sending a multi-part payment, you need to make sure all - * partial payments use the same trampolineSecret. + * @param paymentId id of the outgoing payment (mapped to a single outgoing HTLC). + * @param parentId id of the whole payment. When manually sending a multi-part payment, you need to make sure + * all partial payments use the same parentId. */ - case class SendPaymentToRouteResponse(paymentId: UUID, parentId: UUID, trampolineSecret: Option[ByteVector32]) + case class SendPaymentToRouteResponse(paymentId: UUID, parentId: UUID) /** * Configuration for an instance of a payment state machine. diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/Recipient.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/Recipient.scala index b58413f3b8..e377a4bc3d 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/Recipient.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/Recipient.scala @@ -18,15 +18,14 @@ package fr.acinq.eclair.payment.send import fr.acinq.bitcoin.scalacompat.ByteVector32 import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey -import fr.acinq.eclair.crypto.Sphinx import fr.acinq.eclair.payment.Invoice.ExtraEdge import fr.acinq.eclair.payment.OutgoingPaymentPacket._ import fr.acinq.eclair.payment.send.BlindedPathsResolver.{PartialBlindedRoute, ResolvedPath} -import fr.acinq.eclair.payment.{Bolt11Invoice, Bolt12Invoice, Invoice, OutgoingPaymentPacket} +import fr.acinq.eclair.payment.{Bolt11Invoice, Bolt12Invoice} import fr.acinq.eclair.router.Router._ import fr.acinq.eclair.wire.protocol.PaymentOnion.{FinalPayload, IntermediatePayload, OutgoingBlindedPerHopPayload} import fr.acinq.eclair.wire.protocol.{GenericTlv, OnionRoutingPacket} -import fr.acinq.eclair.{CltvExpiry, Features, InvoiceFeature, MilliSatoshi, MilliSatoshiLong, ShortChannelId} +import fr.acinq.eclair.{CltvExpiry, Features, InvoiceFeature, MilliSatoshi, ShortChannelId} import scodec.bits.ByteVector /** @@ -195,69 +194,3 @@ object BlindedRecipient { BlindedRecipient(nodeId, features, totalAmount, expiry, blindedHops, customTlvs) } } - -/** - * A payment recipient that can be reached through a trampoline node (such recipients usually cannot be found in the - * public graph). Splitting a payment across multiple trampoline nodes is not supported yet, but can easily be added - * with a new field containing a bigger recipient total amount. - * - * Note that we don't need to support the case where we'd use multiple trampoline hops in the same route: since we have - * access to the network graph, it's always more efficient to find a channel route to the last trampoline node. - */ -case class TrampolineRecipient(invoice: Invoice, - totalAmount: MilliSatoshi, - expiry: CltvExpiry, - trampolineHop: NodeHop, - trampolinePaymentSecret: ByteVector32, - customTlvs: Set[GenericTlv] = Set.empty) extends Recipient { - require(trampolineHop.nextNodeId == invoice.nodeId, "trampoline hop must end at the recipient") - - val trampolineNodeId = trampolineHop.nodeId - val trampolineFee = trampolineHop.fee(totalAmount) - val trampolineAmount = totalAmount + trampolineFee - val trampolineExpiry = expiry + trampolineHop.cltvExpiryDelta - - override val nodeId = invoice.nodeId - override val features = invoice.features - override val extraEdges = Seq(ExtraEdge(trampolineNodeId, nodeId, ShortChannelId.generateLocalAlias(), trampolineFee, 0, trampolineHop.cltvExpiryDelta, 1 msat, None)) - - private def validateRoute(route: Route): Either[OutgoingPaymentError, NodeHop] = { - route.finalHop_opt match { - case Some(trampolineHop: NodeHop) => Right(trampolineHop) - case _ => Left(MissingTrampolineHop(trampolineNodeId)) - } - } - - override def buildPayloads(paymentHash: ByteVector32, route: Route): Either[OutgoingPaymentError, PaymentPayloads] = { - for { - trampolineHop <- validateRoute(route) - trampolineOnion <- createTrampolinePacket(paymentHash, trampolineHop) - } yield { - val trampolinePayload = NodePayload(trampolineHop.nodeId, FinalPayload.Standard.createTrampolinePayload(route.amount, trampolineAmount, trampolineExpiry, trampolinePaymentSecret, trampolineOnion.packet)) - Recipient.buildPayloads(PaymentPayloads(route.amount, trampolineExpiry, Seq(trampolinePayload), None), route.hops) - } - } - - private def createTrampolinePacket(paymentHash: ByteVector32, trampolineHop: NodeHop): Either[OutgoingPaymentError, Sphinx.PacketAndSecrets] = { - invoice match { - case invoice: Bolt11Invoice => - if (invoice.features.hasFeature(Features.TrampolinePaymentPrototype)) { - // This is the payload the final recipient will receive, so we use the invoice's payment secret. - val finalPayload = NodePayload(nodeId, FinalPayload.Standard.createPayload(totalAmount, totalAmount, expiry, invoice.paymentSecret, invoice.paymentMetadata, customTlvs)) - val trampolinePayload = NodePayload(trampolineHop.nodeId, IntermediatePayload.NodeRelay.Standard(totalAmount, expiry, nodeId)) - val payloads = Seq(trampolinePayload, finalPayload) - OutgoingPaymentPacket.buildOnion(payloads, paymentHash, packetPayloadLength_opt = None) - } else { - // The recipient doesn't support trampoline: the trampoline node will convert the payment to a non-trampoline payment. - // The final payload will thus never reach the recipient, so we create the smallest payload possible to avoid overflowing the trampoline onion size. - val dummyFinalPayload = NodePayload(nodeId, IntermediatePayload.ChannelRelay.Standard(ShortChannelId(0), 0 msat, CltvExpiry(0))) - val trampolinePayload = NodePayload(trampolineHop.nodeId, IntermediatePayload.NodeRelay.Standard.createNodeRelayToNonTrampolinePayload(totalAmount, totalAmount, expiry, nodeId, invoice)) - val payloads = Seq(trampolinePayload, dummyFinalPayload) - OutgoingPaymentPacket.buildOnion(payloads, paymentHash, packetPayloadLength_opt = None) - } - case invoice: Bolt12Invoice => - val trampolinePayload = NodePayload(trampolineHop.nodeId, IntermediatePayload.NodeRelay.ToBlindedPaths(totalAmount, expiry, invoice)) - OutgoingPaymentPacket.buildOnion(Seq(trampolinePayload), paymentHash, packetPayloadLength_opt = None) - } - } -} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/TrampolinePaymentLifecycle.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/TrampolinePaymentLifecycle.scala new file mode 100644 index 0000000000..8a69b18e07 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/TrampolinePaymentLifecycle.scala @@ -0,0 +1,244 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.payment.send + +import akka.actor.typed.scaladsl.adapter._ +import akka.actor.typed.scaladsl.{ActorContext, Behaviors} +import akka.actor.typed.{ActorRef, Behavior} +import fr.acinq.bitcoin.scalacompat.ByteVector32 +import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey +import fr.acinq.eclair.channel._ +import fr.acinq.eclair.crypto.Sphinx +import fr.acinq.eclair.io.Peer +import fr.acinq.eclair.payment.OutgoingPaymentPacket.{NodePayload, buildOnion} +import fr.acinq.eclair.payment.PaymentSent.PartialPayment +import fr.acinq.eclair.payment._ +import fr.acinq.eclair.router.Router.RouteParams +import fr.acinq.eclair.wire.protocol.{PaymentOnion, PaymentOnionCodecs} +import fr.acinq.eclair.{CltvExpiry, CltvExpiryDelta, Features, Logs, MilliSatoshi, MilliSatoshiLong, NodeParams, ShortChannelId, randomBytes32} + +import java.util.UUID + +/** + * Created by t-bast on 15/10/2024. + */ + +/** + * This actor is responsible for sending a trampoline payment, using a trampoline node from one of our peers. + * This is only meant to be used for tests: eclair nodes need to be able to relay and receive trampoline payments from + * mobile wallets, but they don't need to be able to send trampoline payments since they can always compute the route + * themselves. + * + * This actor thus uses a very simplified state machine to support tests: this is not a robust implementation of what + * a mobile wallet should do when sending trampoline payments. + */ +object TrampolinePaymentLifecycle { + + // @formatter:off + sealed trait Command + case class SendPayment(replyTo: ActorRef[PaymentEvent], paymentId: UUID, trampolineNodeId: PublicKey, invoice: Invoice, routeParams: RouteParams) extends Command { + require(invoice.amount_opt.nonEmpty, "amount-less invoices are not supported in trampoline tests") + } + private case class TrampolinePeerNotFound(trampolineNodeId: PublicKey) extends Command + private case class WrappedPeerChannels(channels: Seq[Peer.ChannelInfo]) extends Command + private case class WrappedAddHtlcResponse(response: CommandResponse[CMD_ADD_HTLC]) extends Command + private case class WrappedHtlcSettled(result: RES_ADD_SETTLED[Origin.Hot, HtlcResult]) extends Command + // @formatter:on + + def apply(nodeParams: NodeParams, register: ActorRef[Register.ForwardNodeId[Peer.GetPeerChannels]]): Behavior[Command] = + Behaviors.setup { context => + Behaviors.receiveMessagePartial { + case cmd: SendPayment => + val mdc = Logs.mdc( + category_opt = Some(Logs.LogCategory.PAYMENT), + remoteNodeId_opt = Some(cmd.trampolineNodeId), + paymentHash_opt = Some(cmd.invoice.paymentHash), + paymentId_opt = Some(cmd.paymentId) + ) + Behaviors.withMdc(mdc) { + new TrampolinePaymentLifecycle(nodeParams, register, cmd, context).start() + } + } + } + +} + +class TrampolinePaymentLifecycle private(nodeParams: NodeParams, + register: ActorRef[Register.ForwardNodeId[Peer.GetPeerChannels]], + cmd: TrampolinePaymentLifecycle.SendPayment, + context: ActorContext[TrampolinePaymentLifecycle.Command]) { + + import TrampolinePayment._ + import TrampolinePaymentLifecycle._ + + private val paymentHash = cmd.invoice.paymentHash + private val totalAmount = cmd.invoice.amount_opt.get + + private val forwardNodeIdFailureAdapter = context.messageAdapter[Register.ForwardNodeIdFailure[Peer.GetPeerChannels]](_ => TrampolinePeerNotFound(cmd.trampolineNodeId)) + private val peerChannelsResponseAdapter = context.messageAdapter[Peer.PeerChannels](c => WrappedPeerChannels(c.channels)) + private val addHtlcAdapter = context.messageAdapter[CommandResponse[CMD_ADD_HTLC]](WrappedAddHtlcResponse) + private val htlcSettledAdapter = context.messageAdapter[RES_ADD_SETTLED[Origin.Hot, HtlcResult]](WrappedHtlcSettled) + + def start(): Behavior[Command] = listChannels(attemptNumber = 0) + + private def listChannels(attemptNumber: Int): Behavior[Command] = { + register ! Register.ForwardNodeId(forwardNodeIdFailureAdapter, cmd.trampolineNodeId, Peer.GetPeerChannels(peerChannelsResponseAdapter)) + Behaviors.receiveMessagePartial { + case TrampolinePeerNotFound(nodeId) => + context.log.warn("could not send trampoline payment: we don't have channels with trampoline node {}", nodeId) + cmd.replyTo ! PaymentFailed(cmd.paymentId, paymentHash, LocalFailure(totalAmount, Nil, new RuntimeException("no channels with trampoline node")) :: Nil) + Behaviors.stopped + case WrappedPeerChannels(channels) => + sendPayment(channels, attemptNumber) + } + } + + private def sendPayment(channels: Seq[Peer.ChannelInfo], attemptNumber: Int): Behavior[Command] = { + val trampolineAmount = computeTrampolineAmount(totalAmount, attemptNumber) + // We always use MPP to verify that the trampoline node is able to handle it. + // This is a very naive way of doing MPP that simply splits the payment in two HTLCs. + val filtered = channels.flatMap(c => { + c.data match { + case d: DATA_NORMAL if d.commitments.availableBalanceForSend > (trampolineAmount / 2) => Some(c) + case _ => None + } + }) + val origin = Origin.Hot(htlcSettledAdapter.toClassic, Upstream.Local(cmd.paymentId)) + val expiry = CltvExpiry(nodeParams.currentBlockHeight) + CltvExpiryDelta(36) + if (filtered.isEmpty) { + context.log.warn("no usable channel with trampoline node {}", cmd.trampolineNodeId) + cmd.replyTo ! PaymentFailed(cmd.paymentId, paymentHash, LocalFailure(totalAmount, Nil, new RuntimeException("no usable channel with trampoline node")) :: Nil) + Behaviors.stopped + } else { + val amount1 = totalAmount / 2 + val channel1 = filtered.head + val amount2 = totalAmount - amount1 + val channel2 = filtered.last + // We generate a random secret to avoid leaking the invoice secret to the trampoline node. + val trampolinePaymentSecret = randomBytes32() + context.log.info("sending trampoline payment parts: {}->{}, {}->{}", channel1.data.channelId, amount1, channel2.data.channelId, amount2) + val parts = Seq((amount1, channel1), (amount2, channel2)).map { case (amount, channelInfo) => + val outgoing = buildOutgoingPayment(cmd.trampolineNodeId, cmd.invoice, amount, expiry, trampolinePaymentSecret, attemptNumber) + val add = CMD_ADD_HTLC(addHtlcAdapter.toClassic, outgoing.trampolineAmount, paymentHash, outgoing.trampolineExpiry, outgoing.onion.packet, None, 1.0, None, origin, commit = true) + channelInfo.channel ! add + val channelId = channelInfo.data.asInstanceOf[DATA_NORMAL].channelId + PartialPayment(cmd.paymentId, amount, computeFees(amount, attemptNumber), channelId, None) + } + waitForSettlement(remaining = 2, attemptNumber, parts) + } + } + + private def waitForSettlement(remaining: Int, attemptNumber: Int, parts: Seq[PartialPayment]): Behavior[Command] = { + Behaviors.receiveMessagePartial { + case WrappedAddHtlcResponse(response) => response match { + case _: CommandSuccess[_] => + // HTLC was correctly sent out. + Behaviors.same + case failure: CommandFailure[_, Throwable] => + context.log.warn("HTLC could not be sent: {}", failure.t.getMessage) + if (remaining > 1) { + context.log.info("waiting for remaining HTLCs to complete") + waitForSettlement(remaining - 1, attemptNumber, parts) + } else { + context.log.warn("trampoline payment failed") + cmd.replyTo ! PaymentFailed(cmd.paymentId, paymentHash, LocalFailure(totalAmount, Nil, failure.t) :: Nil) + Behaviors.stopped + } + } + case WrappedHtlcSettled(result) => result.result match { + case fulfill: HtlcResult.Fulfill => + context.log.info("HTLC was fulfilled") + if (remaining > 1) { + context.log.info("waiting for remaining HTLCs to be fulfilled") + waitForSettlement(remaining - 1, attemptNumber, parts) + } else { + context.log.info("trampoline payment succeeded") + cmd.replyTo ! PaymentSent(cmd.paymentId, paymentHash, fulfill.paymentPreimage, totalAmount, cmd.invoice.nodeId, parts) + Behaviors.stopped + } + case fail: HtlcResult.Fail => + context.log.warn("received HTLC failure: {}", fail) + if (remaining > 1) { + context.log.info("waiting for remaining HTLCs to be failed") + waitForSettlement(remaining - 1, attemptNumber, parts) + } else { + retryOrStop(attemptNumber + 1) + } + } + } + } + + private def retryOrStop(attemptNumber: Int): Behavior[Command] = { + val nextFees = computeFees(totalAmount, attemptNumber) + if (attemptNumber > 3) { + context.log.warn("cannot retry trampoline payment: retries exceeded") + cmd.replyTo ! PaymentFailed(cmd.paymentId, paymentHash, LocalFailure(totalAmount, Nil, new RuntimeException("maximum trampoline retries exceeded")) :: Nil) + Behaviors.stopped + } else if (cmd.routeParams.getMaxFee(totalAmount) < nextFees) { + context.log.warn("cannot retry trampoline payment: maximum fees exceeded ({} > {})", nextFees, cmd.routeParams.getMaxFee(totalAmount)) + cmd.replyTo ! PaymentFailed(cmd.paymentId, paymentHash, LocalFailure(totalAmount, Nil, new RuntimeException("maximum trampoline fees exceeded")) :: Nil) + Behaviors.stopped + } else { + context.log.info("retrying trampoline payment with fees={}", nextFees) + listChannels(attemptNumber) + } + } + +} + +object TrampolinePayment { + + case class OutgoingPayment(trampolineAmount: MilliSatoshi, trampolineExpiry: CltvExpiry, onion: Sphinx.PacketAndSecrets, trampolineOnion: Sphinx.PacketAndSecrets) + + def buildOutgoingPayment(trampolineNodeId: PublicKey, invoice: Invoice, expiry: CltvExpiry): OutgoingPayment = { + buildOutgoingPayment(trampolineNodeId, invoice, invoice.amount_opt.get, expiry, trampolinePaymentSecret = randomBytes32(), attemptNumber = 0) + } + + def buildOutgoingPayment(trampolineNodeId: PublicKey, invoice: Invoice, amount: MilliSatoshi, expiry: CltvExpiry, trampolinePaymentSecret: ByteVector32, attemptNumber: Int): OutgoingPayment = { + val totalAmount = invoice.amount_opt.get + val trampolineOnion = invoice match { + case invoice: Bolt11Invoice if invoice.features.hasFeature(Features.TrampolinePaymentPrototype) => + val finalPayload = PaymentOnion.FinalPayload.Standard.createPayload(amount, totalAmount, expiry, invoice.paymentSecret, invoice.paymentMetadata) + val trampolinePayload = PaymentOnion.IntermediatePayload.NodeRelay.Standard(totalAmount, expiry, invoice.nodeId) + buildOnion(NodePayload(trampolineNodeId, trampolinePayload) :: NodePayload(invoice.nodeId, finalPayload) :: Nil, invoice.paymentHash, None).toOption.get + case invoice: Bolt11Invoice => + // The recipient doesn't support trampoline: the trampoline node will convert the payment to a non-trampoline payment. + // The final payload will thus never reach the recipient, so we create the smallest payload possible to avoid overflowing the trampoline onion size. + val dummyPayload = PaymentOnion.IntermediatePayload.ChannelRelay.Standard(ShortChannelId(0), 0 msat, CltvExpiry(0)) + val trampolinePayload = PaymentOnion.IntermediatePayload.NodeRelay.Standard.createNodeRelayToNonTrampolinePayload(totalAmount, totalAmount, expiry, invoice.nodeId, invoice) + buildOnion(NodePayload(trampolineNodeId, trampolinePayload) :: NodePayload(invoice.nodeId, dummyPayload) :: Nil, invoice.paymentHash, None).toOption.get + case invoice: Bolt12Invoice => + val trampolinePayload = PaymentOnion.IntermediatePayload.NodeRelay.ToBlindedPaths(totalAmount, expiry, invoice) + buildOnion(NodePayload(trampolineNodeId, trampolinePayload) :: Nil, invoice.paymentHash, None).toOption.get + } + val trampolineAmount = computeTrampolineAmount(amount, attemptNumber) + val trampolineTotalAmount = computeTrampolineAmount(totalAmount, attemptNumber) + val trampolineExpiry = computeTrampolineExpiry(expiry, attemptNumber) + val payload = PaymentOnion.FinalPayload.Standard.createTrampolinePayload(trampolineAmount, trampolineTotalAmount, trampolineExpiry, trampolinePaymentSecret, trampolineOnion.packet) + val paymentOnion = buildOnion(NodePayload(trampolineNodeId, payload) :: Nil, invoice.paymentHash, Some(PaymentOnionCodecs.paymentOnionPayloadLength)).toOption.get + OutgoingPayment(trampolineAmount, trampolineExpiry, paymentOnion, trampolineOnion) + } + + // We increase the fees paid by 0.2% of the amount sent at each attempt. + def computeFees(amount: MilliSatoshi, attemptNumber: Int): MilliSatoshi = amount * (attemptNumber + 1) * 0.002 + + def computeTrampolineAmount(amount: MilliSatoshi, attemptNumber: Int): MilliSatoshi = amount + computeFees(amount, attemptNumber) + + // We increase the trampoline expiry delta at each attempt. + private def computeTrampolineExpiry(expiry: CltvExpiry, attemptNumber: Int): CltvExpiry = expiry + CltvExpiryDelta(144) * (attemptNumber + 1) + +} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/router/RouteCalculation.scala b/eclair-core/src/main/scala/fr/acinq/eclair/router/RouteCalculation.scala index 8c8e524afb..f8ce23b9fc 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/router/RouteCalculation.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/router/RouteCalculation.scala @@ -22,7 +22,6 @@ import com.softwaremill.quicklens.ModifyPimp import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.eclair.Logs.LogCategory import fr.acinq.eclair._ -import fr.acinq.eclair.message.SendingMessage import fr.acinq.eclair.payment.send._ import fr.acinq.eclair.router.Graph.GraphStructure.DirectedGraph.graphEdgeToHop import fr.acinq.eclair.router.Graph.GraphStructure.{DirectedGraph, GraphEdge} @@ -164,14 +163,6 @@ object RouteCalculation { // In that case, we will slightly over-estimate the fee we're paying, but at least we won't exceed our fee budget. val maxFee = totalMaxFee - pendingChannelFee - r.pendingPayments.map(_.blindedFee).sum (targetNodeId, amountToSend, maxFee, extraEdges) - case recipient: TrampolineRecipient => - // Trampoline payments require finding routes to the trampoline node, not the final recipient. - // This also ensures that we correctly take the trampoline fee into account only once, even when using MPP to - // reach the trampoline node (which will aggregate the incoming MPP payment and re-split as necessary). - val targetNodeId = recipient.trampolineHop.nodeId - val amountToSend = recipient.trampolineAmount - pendingAmount - val maxFee = totalMaxFee - pendingChannelFee - recipient.trampolineFee - (targetNodeId, amountToSend, maxFee, Set.empty) } } @@ -180,7 +171,6 @@ object RouteCalculation { recipient match { case _: ClearRecipient => Some(route) case _: SpontaneousRecipient => Some(route) - case recipient: TrampolineRecipient => Some(route.copy(finalHop_opt = Some(recipient.trampolineHop))) case recipient: BlindedRecipient => route.hops.lastOption.flatMap { hop => recipient.blindedHops.find(_.dummyId == hop.shortChannelId) @@ -239,7 +229,7 @@ object RouteCalculation { } } - def handleMessageRouteRequest(d: Data, currentBlockHeight: BlockHeight, r: MessageRouteRequest, routeParams: MessageRouteParams)(implicit ctx: ActorContext, log: DiagnosticLoggingAdapter): Data = { + def handleMessageRouteRequest(d: Data, currentBlockHeight: BlockHeight, r: MessageRouteRequest, routeParams: MessageRouteParams)(implicit log: DiagnosticLoggingAdapter): Data = { val boundaries: MessagePath.RichWeight => Boolean = { weight => weight.length <= routeParams.maxRouteLength && weight.length <= ROUTE_MAX_LENGTH } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala index cdfdc5d46a..934c5d6365 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala @@ -343,14 +343,13 @@ class EclairImplSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with I val parentId = UUID.randomUUID() val secret = randomBytes32() val pr = Bolt11Invoice(Block.LivenetGenesisBlock.hash, Some(1234 msat), ByteVector32.One, randomKey(), Right(randomBytes32()), CltvExpiryDelta(18)) - eclair.sendToRoute(Some(1200 msat), Some("42"), Some(parentId), pr, route, Some(secret), Some(100 msat), Some(CltvExpiryDelta(144))) + eclair.sendToRoute(Some(1200 msat), Some("42"), Some(parentId), pr, route) val sendPaymentToRoute = paymentInitiator.expectMsgType[SendPaymentToRoute] assert(sendPaymentToRoute.recipientAmount == 1200.msat) assert(sendPaymentToRoute.invoice == pr) assert(sendPaymentToRoute.route == route) assert(sendPaymentToRoute.externalId.contains("42")) assert(sendPaymentToRoute.parentId.contains(parentId)) - assert(sendPaymentToRoute.trampoline_opt.contains(TrampolineAttempt(secret, 100 msat, CltvExpiryDelta(144)))) } test("find routes") { f => 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 571508d811..3ba9d1ad5b 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 @@ -462,20 +462,17 @@ class PaymentIntegrationSpec extends IntegrationSpec { assert(math.abs((canSend - canSend2).toLong) < 50000000) } - test("send a trampoline payment B->F1 with retry (via trampoline G)") { + test("send a trampoline payment B->F1 (via trampoline G)") { val start = TimestampMilli.now() val sender = TestProbe() - val amount = 4000000000L.msat + val amount = 4_000_000_000L.msat sender.send(nodes("F").paymentHandler, ReceiveStandardPayment(sender.ref, Some(amount), Left("like trampoline much?"))) val invoice = sender.expectMsgType[Bolt11Invoice] assert(invoice.features.hasFeature(Features.BasicMultiPartPayment)) assert(invoice.features.hasFeature(Features.TrampolinePaymentPrototype)) - // The best route from G is G -> C -> F which has a fee of 1210091 msat - - // The first attempt should fail, but the second one should succeed. - val attempts = (1210000 msat, CltvExpiryDelta(42)) :: (1210100 msat, CltvExpiryDelta(288)) :: Nil - val payment = SendTrampolinePayment(sender.ref, amount, invoice, nodes("G").nodeParams.nodeId, attempts, routeParams = integrationTestRouteParams) + // The best route from G is G -> C -> F. + val payment = SendTrampolinePayment(sender.ref, invoice, nodes("G").nodeParams.nodeId, routeParams = integrationTestRouteParams) sender.send(nodes("B").paymentInitiator, payment) val paymentId = sender.expectMsgType[UUID] val paymentSent = sender.expectMsgType[PaymentSent](max = 30 seconds) @@ -483,9 +480,7 @@ class PaymentIntegrationSpec extends IntegrationSpec { assert(paymentSent.paymentHash == invoice.paymentHash, paymentSent) assert(paymentSent.recipientNodeId == nodes("F").nodeParams.nodeId, paymentSent) assert(paymentSent.recipientAmount == amount, paymentSent) - assert(paymentSent.trampolineFees == 1210100.msat, paymentSent) - assert(paymentSent.nonTrampolineFees == 0.msat, paymentSent) - assert(paymentSent.feesPaid == 1210100.msat, paymentSent) + assert(paymentSent.feesPaid == amount * 0.002) // 0.2% awaitCond(nodes("F").nodeParams.db.payments.getIncomingPayment(invoice.paymentHash).exists(_.status.isInstanceOf[IncomingPaymentStatus.Received])) val Some(IncomingStandardPayment(_, _, _, _, IncomingPaymentStatus.Received(receivedAmount, _))) = nodes("F").nodeParams.db.payments.getIncomingPayment(invoice.paymentHash) @@ -497,41 +492,28 @@ class PaymentIntegrationSpec extends IntegrationSpec { }) val relayed = nodes("G").nodeParams.db.audit.listRelayed(start, TimestampMilli.now()).filter(_.paymentHash == invoice.paymentHash).head assert(relayed.amountIn - relayed.amountOut > 0.msat, relayed) - assert(relayed.amountIn - relayed.amountOut < 1210100.msat, relayed) - - val outgoingSuccess = nodes("B").nodeParams.db.payments.listOutgoingPayments(paymentId).filter(p => p.status.isInstanceOf[OutgoingPaymentStatus.Succeeded]).head - assert(outgoingSuccess.recipientNodeId == nodes("F").nodeParams.nodeId, outgoingSuccess) - assert(outgoingSuccess.recipientAmount == amount, outgoingSuccess) - assert(outgoingSuccess.amount == amount + 1210100.msat, outgoingSuccess) - val status = outgoingSuccess.status.asInstanceOf[OutgoingPaymentStatus.Succeeded] - assert(status.route.lastOption.contains(HopSummary(nodes("G").nodeParams.nodeId, nodes("F").nodeParams.nodeId)), status) + assert(relayed.amountIn - relayed.amountOut < paymentSent.feesPaid, relayed) } test("send a trampoline payment D->B (via trampoline C)") { val start = TimestampMilli.now() val (sender, eventListener) = (TestProbe(), TestProbe()) nodes("B").system.eventStream.subscribe(eventListener.ref, classOf[PaymentMetadataReceived]) - val amount = 2500000000L.msat + val amount = 2_500_000_000L.msat sender.send(nodes("B").paymentHandler, ReceiveStandardPayment(sender.ref, Some(amount), Left("trampoline-MPP is so #reckless"))) val invoice = sender.expectMsgType[Bolt11Invoice] assert(invoice.features.hasFeature(Features.BasicMultiPartPayment)) assert(invoice.features.hasFeature(Features.TrampolinePaymentPrototype)) assert(invoice.paymentMetadata.nonEmpty) - // The direct route C -> B does not have enough capacity, the payment will be split between - // C -> B which would have a fee of 501000 if it could route the whole payment - // C -> G -> B which would have a fee of 757061 if it was used to route the whole payment - // The actual fee needed will be between these two values. - val payment = SendTrampolinePayment(sender.ref, amount, invoice, nodes("C").nodeParams.nodeId, Seq((750000 msat, CltvExpiryDelta(288))), routeParams = integrationTestRouteParams) + // The direct route C -> B does not have enough capacity, the payment will be split between C -> B and C -> G -> B + val payment = SendTrampolinePayment(sender.ref, invoice, nodes("C").nodeParams.nodeId, routeParams = integrationTestRouteParams) sender.send(nodes("D").paymentInitiator, payment) val paymentId = sender.expectMsgType[UUID] val paymentSent = sender.expectMsgType[PaymentSent](max = 30 seconds) assert(paymentSent.id == paymentId, paymentSent) assert(paymentSent.paymentHash == invoice.paymentHash, paymentSent) assert(paymentSent.recipientAmount == amount, paymentSent) - assert(paymentSent.trampolineFees == 750000.msat, paymentSent) - assert(paymentSent.nonTrampolineFees == 0.msat, paymentSent) - assert(paymentSent.feesPaid == 750000.msat, paymentSent) awaitCond(nodes("B").nodeParams.db.payments.getIncomingPayment(invoice.paymentHash).exists(_.status.isInstanceOf[IncomingPaymentStatus.Received])) val Some(IncomingStandardPayment(_, _, _, _, IncomingPaymentStatus.Received(receivedAmount, _))) = nodes("B").nodeParams.db.payments.getIncomingPayment(invoice.paymentHash) @@ -544,19 +526,7 @@ class PaymentIntegrationSpec extends IntegrationSpec { }) val relayed = nodes("C").nodeParams.db.audit.listRelayed(start, TimestampMilli.now()).filter(_.paymentHash == invoice.paymentHash).head assert(relayed.amountIn - relayed.amountOut > 0.msat, relayed) - assert(relayed.amountIn - relayed.amountOut < 750000.msat, relayed) - - val outgoingSuccess = nodes("D").nodeParams.db.payments.listOutgoingPayments(paymentId).filter(p => p.status.isInstanceOf[OutgoingPaymentStatus.Succeeded]).head - assert(outgoingSuccess.recipientNodeId == nodes("B").nodeParams.nodeId, outgoingSuccess) - assert(outgoingSuccess.recipientAmount == amount, outgoingSuccess) - assert(outgoingSuccess.amount == amount + 750000.msat, outgoingSuccess) - val status = outgoingSuccess.status.asInstanceOf[OutgoingPaymentStatus.Succeeded] - assert(status.route.lastOption.contains(HopSummary(nodes("C").nodeParams.nodeId, nodes("B").nodeParams.nodeId)), status) - - awaitCond(nodes("D").nodeParams.db.audit.listSent(start, TimestampMilli.now()).nonEmpty) - val sent = nodes("D").nodeParams.db.audit.listSent(start, TimestampMilli.now()) - assert(sent.length == 1, sent) - assert(sent.head.copy(parts = sent.head.parts.sortBy(_.timestamp)) == paymentSent.copy(parts = paymentSent.parts.map(_.copy(route = None)).sortBy(_.timestamp)), sent) + assert(relayed.amountIn - relayed.amountOut < paymentSent.feesPaid, relayed) } test("send a trampoline payment F1->A (via trampoline C, non-trampoline recipient)") { @@ -568,22 +538,20 @@ class PaymentIntegrationSpec extends IntegrationSpec { sender.send(nodes("A").router, Router.GetRouterData) val routingHints = List(sender.expectMsgType[Router.Data].privateChannels.head._2.toIncomingExtraHop.toList) - val amount = 3000000000L.msat + val amount = 3_000_000_000L.msat sender.send(nodes("A").paymentHandler, ReceiveStandardPayment(sender.ref, Some(amount), Left("trampoline to non-trampoline is so #vintage"), extraHops = routingHints)) val invoice = sender.expectMsgType[Bolt11Invoice] assert(invoice.features.hasFeature(Features.BasicMultiPartPayment)) assert(!invoice.features.hasFeature(Features.TrampolinePaymentPrototype)) assert(invoice.paymentMetadata.nonEmpty) - val payment = SendTrampolinePayment(sender.ref, amount, invoice, nodes("C").nodeParams.nodeId, Seq((1500000 msat, CltvExpiryDelta(432))), routeParams = integrationTestRouteParams) + val payment = SendTrampolinePayment(sender.ref, invoice, nodes("C").nodeParams.nodeId, routeParams = integrationTestRouteParams) sender.send(nodes("F").paymentInitiator, payment) val paymentId = sender.expectMsgType[UUID] val paymentSent = sender.expectMsgType[PaymentSent](max = 30 seconds) assert(paymentSent.id == paymentId, paymentSent) assert(paymentSent.paymentHash == invoice.paymentHash, paymentSent) assert(paymentSent.recipientAmount == amount, paymentSent) - assert(paymentSent.trampolineFees == 1500000.msat, paymentSent) - assert(paymentSent.nonTrampolineFees == 0.msat, paymentSent) awaitCond(nodes("A").nodeParams.db.payments.getIncomingPayment(invoice.paymentHash).exists(_.status.isInstanceOf[IncomingPaymentStatus.Received])) val Some(IncomingStandardPayment(_, _, _, _, IncomingPaymentStatus.Received(receivedAmount, _))) = nodes("A").nodeParams.db.payments.getIncomingPayment(invoice.paymentHash) @@ -596,96 +564,48 @@ class PaymentIntegrationSpec extends IntegrationSpec { }) val relayed = nodes("C").nodeParams.db.audit.listRelayed(start, TimestampMilli.now()).filter(_.paymentHash == invoice.paymentHash).head assert(relayed.amountIn - relayed.amountOut > 0.msat, relayed) - assert(relayed.amountIn - relayed.amountOut < 1500000.msat, relayed) - - val outgoingSuccess = nodes("F").nodeParams.db.payments.listOutgoingPayments(paymentId).filter(p => p.status.isInstanceOf[OutgoingPaymentStatus.Succeeded]).head - assert(outgoingSuccess.recipientNodeId == nodes("A").nodeParams.nodeId, outgoingSuccess) - assert(outgoingSuccess.recipientAmount == amount, outgoingSuccess) - assert(outgoingSuccess.amount == amount + 1500000.msat, outgoingSuccess) - val status = outgoingSuccess.status.asInstanceOf[OutgoingPaymentStatus.Succeeded] - assert(status.route.lastOption.contains(HopSummary(nodes("C").nodeParams.nodeId, nodes("A").nodeParams.nodeId)), status) + assert(relayed.amountIn - relayed.amountOut < paymentSent.feesPaid, relayed) } test("send a trampoline payment B->D (temporary local failure at trampoline)") { val sender = TestProbe() // We put most of the capacity C <-> D on D's side. - sender.send(nodes("D").paymentHandler, ReceiveStandardPayment(sender.ref, Some(8000000000L msat), Left("plz send everything"))) + sender.send(nodes("D").paymentHandler, ReceiveStandardPayment(sender.ref, Some(8_000_000_000L msat), Left("plz send everything"))) val pr1 = sender.expectMsgType[Bolt11Invoice] - sender.send(nodes("C").paymentInitiator, SendPaymentToNode(sender.ref, 8000000000L msat, pr1, Nil, maxAttempts = 3, routeParams = integrationTestRouteParams)) + sender.send(nodes("C").paymentInitiator, SendPaymentToNode(sender.ref, 8_000_000_000L msat, pr1, Nil, maxAttempts = 3, routeParams = integrationTestRouteParams)) sender.expectMsgType[UUID] sender.expectMsgType[PaymentSent](max = 30 seconds) // Now we try to send more than C's outgoing capacity to D. - val amount = 2000000000L.msat + val amount = 1_800_000_000L.msat sender.send(nodes("D").paymentHandler, ReceiveStandardPayment(sender.ref, Some(amount), Left("I iz Satoshi"))) val invoice = sender.expectMsgType[Bolt11Invoice] assert(invoice.features.hasFeature(Features.BasicMultiPartPayment)) assert(invoice.features.hasFeature(Features.TrampolinePaymentPrototype)) - val payment = SendTrampolinePayment(sender.ref, amount, invoice, nodes("C").nodeParams.nodeId, Seq((250000 msat, CltvExpiryDelta(288))), routeParams = integrationTestRouteParams) + val payment = SendTrampolinePayment(sender.ref, invoice, nodes("C").nodeParams.nodeId, routeParams = integrationTestRouteParams) sender.send(nodes("B").paymentInitiator, payment) val paymentId = sender.expectMsgType[UUID] val paymentFailed = sender.expectMsgType[PaymentFailed](max = 30 seconds) assert(paymentFailed.id == paymentId, paymentFailed) assert(paymentFailed.paymentHash == invoice.paymentHash, paymentFailed) - - assert(nodes("D").nodeParams.db.payments.getIncomingPayment(invoice.paymentHash).get.status == IncomingPaymentStatus.Pending) - val outgoingPayments = nodes("B").nodeParams.db.payments.listOutgoingPayments(paymentId) - assert(outgoingPayments.nonEmpty, outgoingPayments) - assert(outgoingPayments.forall(p => p.status.isInstanceOf[OutgoingPaymentStatus.Failed]), outgoingPayments) } test("send a trampoline payment A->D (temporary remote failure at trampoline)") { val sender = TestProbe() - val amount = 2000000000L.msat // B can forward to C, but C doesn't have that much outgoing capacity to D + val amount = 1_800_000_000L.msat // B can forward to C, but C doesn't have that much outgoing capacity to D sender.send(nodes("D").paymentHandler, ReceiveStandardPayment(sender.ref, Some(amount), Left("I iz not Satoshi"))) val invoice = sender.expectMsgType[Bolt11Invoice] assert(invoice.features.hasFeature(Features.BasicMultiPartPayment)) assert(invoice.features.hasFeature(Features.TrampolinePaymentPrototype)) - val payment = SendTrampolinePayment(sender.ref, amount, invoice, nodes("B").nodeParams.nodeId, Seq((450000 msat, CltvExpiryDelta(288))), routeParams = integrationTestRouteParams) + val payment = SendTrampolinePayment(sender.ref, invoice, nodes("B").nodeParams.nodeId, routeParams = integrationTestRouteParams) sender.send(nodes("A").paymentInitiator, payment) val paymentId = sender.expectMsgType[UUID] val paymentFailed = sender.expectMsgType[PaymentFailed](max = 30 seconds) assert(paymentFailed.id == paymentId, paymentFailed) assert(paymentFailed.paymentHash == invoice.paymentHash, paymentFailed) - - assert(nodes("D").nodeParams.db.payments.getIncomingPayment(invoice.paymentHash).get.status == IncomingPaymentStatus.Pending) - val outgoingPayments = nodes("A").nodeParams.db.payments.listOutgoingPayments(paymentId) - assert(outgoingPayments.nonEmpty, outgoingPayments) - assert(outgoingPayments.forall(p => p.status.isInstanceOf[OutgoingPaymentStatus.Failed]), outgoingPayments) - } - - test("send a trampoline payment A->D (via remote trampoline C)") { - val sender = TestProbe() - val amount = 500000000L.msat - sender.send(nodes("D").paymentHandler, ReceiveStandardPayment(sender.ref, Some(amount), Left("remote trampoline is so #reckless"))) - val invoice = sender.expectMsgType[Bolt11Invoice] - assert(invoice.features.hasFeature(Features.BasicMultiPartPayment)) - assert(invoice.features.hasFeature(Features.TrampolinePaymentPrototype)) - - val payment = SendTrampolinePayment(sender.ref, amount, invoice, nodes("C").nodeParams.nodeId, Seq((500000 msat, CltvExpiryDelta(288))), routeParams = integrationTestRouteParams) - sender.send(nodes("A").paymentInitiator, payment) - val paymentId = sender.expectMsgType[UUID] - val paymentSent = sender.expectMsgType[PaymentSent](max = 30 seconds) - assert(paymentSent.id == paymentId, paymentSent) - assert(paymentSent.paymentHash == invoice.paymentHash, paymentSent) - assert(paymentSent.recipientAmount == amount, paymentSent) - assert(paymentSent.trampolineFees == 500000.msat, paymentSent) - assert(paymentSent.nonTrampolineFees > 0.msat, paymentSent) - assert(paymentSent.feesPaid > 500000.msat, paymentSent) - - awaitCond(nodes("D").nodeParams.db.payments.getIncomingPayment(invoice.paymentHash).exists(_.status.isInstanceOf[IncomingPaymentStatus.Received])) - val Some(IncomingStandardPayment(_, _, _, _, IncomingPaymentStatus.Received(receivedAmount, _))) = nodes("D").nodeParams.db.payments.getIncomingPayment(invoice.paymentHash) - assert(receivedAmount == amount) - - val outgoingSuccess = nodes("A").nodeParams.db.payments.listOutgoingPayments(paymentId).filter(p => p.status.isInstanceOf[OutgoingPaymentStatus.Succeeded]).head - assert(outgoingSuccess.recipientNodeId == nodes("D").nodeParams.nodeId, outgoingSuccess) - assert(outgoingSuccess.recipientAmount == amount, outgoingSuccess) - assert(outgoingSuccess.amount == amount + 500000.msat, outgoingSuccess) - val status = outgoingSuccess.status.asInstanceOf[OutgoingPaymentStatus.Succeeded] - assert(status.route.lastOption.contains(HopSummary(nodes("C").nodeParams.nodeId, nodes("D").nodeParams.nodeId)), status) } test("send a blinded payment B->D with many blinded routes") { @@ -838,29 +758,23 @@ class PaymentIntegrationSpec extends IntegrationSpec { val sender = TestProbe() val alice = new EclairImpl(nodes("A")) - alice.payOfferTrampoline(offer, amount, 1, nodes("B").nodeParams.nodeId, Seq((10_000 msat, CltvExpiryDelta(1000))), maxAttempts_opt = Some(1))(30 seconds).pipeTo(sender.ref) + alice.payOfferTrampoline(offer, amount, 1, nodes("B").nodeParams.nodeId, maxAttempts_opt = Some(1))(30 seconds).pipeTo(sender.ref) val handleInvoiceRequest = offerHandler.expectMessageType[HandleInvoiceRequest] val receivingRoutes = Seq(ReceivingRoute(Seq(nodes("C").nodeParams.nodeId, nodes("D").nodeParams.nodeId), CltvExpiryDelta(500))) handleInvoiceRequest.replyTo ! InvoiceRequestActor.ApproveRequest(amount, receivingRoutes, pluginData_opt = Some(hex"0123")) - val paymentId = sender.expectMsgType[UUID] - val handlePayment = offerHandler.expectMessageType[HandlePayment] assert(handlePayment.offerId == offer.offerId) assert(handlePayment.pluginData_opt.contains(hex"0123")) handlePayment.replyTo ! PaymentActor.AcceptPayment() - awaitCond(nodes("A").nodeParams.db.payments.listOutgoingPayments(paymentId).headOption.exists(_.status.isInstanceOf[OutgoingPaymentStatus.Succeeded])) - val Some(OutgoingPaymentStatus.Succeeded(preimage, _, route, _)) = nodes("A").nodeParams.db.payments.listOutgoingPayments(paymentId).headOption.map(_.status) - assert(route.length == 2) - assert(route.head.shortChannelId.nonEmpty) - assert(route.head.nodeId == nodes("A").nodeParams.nodeId) - assert(route.head.nextNodeId == nodes("B").nodeParams.nodeId) - assert(route.last.shortChannelId.isEmpty) - assert(route.last.nodeId == nodes("B").nodeParams.nodeId) - assert(route.last.nextNodeId == nodes("D").nodeParams.nodeId) - val Some(IncomingBlindedPayment(_, _, _, _, IncomingPaymentStatus.Received(receivedAmount, _))) = nodes("D").nodeParams.db.payments.getIncomingPayment(Crypto.sha256(preimage)) + val paymentSent = sender.expectMsgType[PaymentSent] + assert(paymentSent.recipientAmount == amount, paymentSent) + assert(paymentSent.feesPaid >= 0.msat, paymentSent) + + awaitCond(nodes("D").nodeParams.db.payments.getIncomingPayment(paymentSent.paymentHash).exists(_.status.isInstanceOf[IncomingPaymentStatus.Received])) + val Some(IncomingBlindedPayment(_, _, _, _, IncomingPaymentStatus.Received(receivedAmount, _))) = nodes("D").nodeParams.db.payments.getIncomingPayment(paymentSent.paymentHash) assert(receivedAmount >= amount) } 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 0d75451d91..d85030a989 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 @@ -95,8 +95,6 @@ class MultiPartPaymentLifecycleSpec extends TestKitBaseClass with FixtureAnyFunS val result = fulfillPendingPayments(f, 1, e, finalAmount) assert(result.amountWithFees == finalAmount + 100.msat) - assert(result.trampolineFees == 0.msat) - assert(result.nonTrampolineFees == 100.msat) val metrics = metricsListener.expectMsgType[PathFindingExperimentMetrics] assert(metrics.status == "SUCCESS") @@ -130,7 +128,6 @@ class MultiPartPaymentLifecycleSpec extends TestKitBaseClass with FixtureAnyFunS val result = fulfillPendingPayments(f, 2, e, 1_200_000 msat) assert(result.amountWithFees == 1_200_200.msat) - assert(result.nonTrampolineFees == 200.msat) val metrics = metricsListener.expectMsgType[PathFindingExperimentMetrics] assert(metrics.status == "SUCCESS") @@ -140,43 +137,6 @@ class MultiPartPaymentLifecycleSpec extends TestKitBaseClass with FixtureAnyFunS metricsListener.expectNoMessage() } - test("successful first attempt (trampoline)") { f => - import f._ - - assert(payFsm.stateName == WAIT_FOR_PAYMENT_REQUEST) - val invoice = Bolt11Invoice(Block.RegtestGenesisBlock.hash, Some(finalAmount), randomBytes32(), randomKey(), Left("invoice"), CltvExpiryDelta(12)) - val trampolineHop = NodeHop(e, invoice.nodeId, CltvExpiryDelta(50), 1000 msat) - val recipient = TrampolineRecipient(invoice, finalAmount, expiry, trampolineHop, randomBytes32()) - val payment = SendMultiPartPayment(sender.ref, recipient, 1, routeParams) - sender.send(payFsm, payment) - - router.expectMsg(RouteRequest(nodeParams.nodeId, recipient, routeParams.copy(randomize = false), allowMultiPart = true, paymentContext = Some(cfg.paymentContext))) - assert(payFsm.stateName == WAIT_FOR_ROUTES) - - val routes = Seq( - Route(500_000 msat, hop_ab_1 :: hop_be :: Nil, Some(trampolineHop)), - Route(501_000 msat, hop_ac_1 :: hop_ce :: Nil, Some(trampolineHop)) - ) - router.send(payFsm, RouteResponse(routes)) - val childPayments = childPayFsm.expectMsgType[SendPaymentToRoute] :: childPayFsm.expectMsgType[SendPaymentToRoute] :: Nil - assert(childPayments.map(_.route).toSet == routes.map(r => Right(r)).toSet) - childPayments.foreach(childPayment => assert(childPayment.recipient == recipient)) - assert(childPayments.map(_.amount).toSet == Set(500_000 msat, 501_000 msat)) - assert(payFsm.stateName == PAYMENT_IN_PROGRESS) - - val result = fulfillPendingPayments(f, 2, invoice.nodeId, finalAmount) - assert(result.amountWithFees == 1_001_200.msat) - assert(result.trampolineFees == 1000.msat) - assert(result.nonTrampolineFees == 200.msat) - - val metrics = metricsListener.expectMsgType[PathFindingExperimentMetrics] - assert(metrics.status == "SUCCESS") - assert(metrics.experimentName == "my-test-experiment") - assert(metrics.amount == finalAmount) - assert(metrics.fees == 1200.msat) - metricsListener.expectNoMessage() - } - test("successful first attempt (blinded)") { f => import f._ @@ -201,7 +161,6 @@ class MultiPartPaymentLifecycleSpec extends TestKitBaseClass with FixtureAnyFunS val result = fulfillPendingPayments(f, 2, recipient.nodeId, finalAmount) assert(result.amountWithFees == 1_000_200.msat) - assert(result.nonTrampolineFees == 200.msat) } test("successful retry") { f => @@ -226,8 +185,6 @@ class MultiPartPaymentLifecycleSpec extends TestKitBaseClass with FixtureAnyFunS val result = fulfillPendingPayments(f, 2, e, finalAmount) assert(result.amountWithFees == 1_000_200.msat) - assert(result.trampolineFees == 0.msat) - assert(result.nonTrampolineFees == 200.msat) val metrics = metricsListener.expectMsgType[PathFindingExperimentMetrics] assert(metrics.status == "SUCCESS") @@ -269,7 +226,6 @@ class MultiPartPaymentLifecycleSpec extends TestKitBaseClass with FixtureAnyFunS val result = fulfillPendingPayments(f, 2, e, finalAmount) assert(result.amountWithFees == 1_000_200.msat) - assert(result.nonTrampolineFees == 200.msat) val metrics = metricsListener.expectMsgType[PathFindingExperimentMetrics] assert(metrics.status == "SUCCESS") @@ -584,7 +540,6 @@ class MultiPartPaymentLifecycleSpec extends TestKitBaseClass with FixtureAnyFunS val result = fulfillPendingPayments(f, 1, e, finalAmount) assert(result.amountWithFees < finalAmount) // we got the preimage without paying the full amount - assert(result.nonTrampolineFees == successRoute.channelFee(false)) // we paid the fee for only one of the partial payments assert(result.parts.length == 1 && result.parts.head.id == successId) } @@ -613,7 +568,6 @@ class MultiPartPaymentLifecycleSpec extends TestKitBaseClass with FixtureAnyFunS assert(result.recipientAmount == finalAmount) assert(result.recipientNodeId == e) assert(result.amountWithFees < finalAmount) // we got the preimage without paying the full amount - assert(result.nonTrampolineFees == successRoute.channelFee(false)) // we paid the fee for only one of the partial payments sender.expectTerminated(payFsm) sender.expectNoMessage(100 millis) @@ -641,7 +595,6 @@ class MultiPartPaymentLifecycleSpec extends TestKitBaseClass with FixtureAnyFunS val result = sender.expectMsgType[PaymentSent] assert(result.parts.length == 1 && result.parts.head.id == childId) assert(result.amountWithFees < finalAmount) // we got the preimage without paying the full amount - assert(result.nonTrampolineFees == route.channelFee(false)) // we paid the fee for only one of the partial payments sender.expectTerminated(payFsm) sender.expectNoMessage(100 millis) 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 8888f7d2fc..69464e1608 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 @@ -16,7 +16,8 @@ package fr.acinq.eclair.payment -import akka.actor.{ActorContext, ActorRef} +import akka.actor.typed.scaladsl.adapter._ +import akka.actor.{ActorContext, ActorRef, typed} import akka.testkit.{TestActorRef, TestProbe} import fr.acinq.bitcoin.scalacompat.Block import fr.acinq.bitcoin.scalacompat.Crypto.PrivateKey @@ -25,7 +26,6 @@ import fr.acinq.eclair.Features._ import fr.acinq.eclair.UInt64.Conversions._ import fr.acinq.eclair.channel.Upstream import fr.acinq.eclair.channel.fsm.Channel -import fr.acinq.eclair.crypto.Sphinx import fr.acinq.eclair.payment.Bolt11Invoice.ExtraHop import fr.acinq.eclair.payment.PaymentPacketSpec._ import fr.acinq.eclair.payment.PaymentSent.PartialPayment @@ -34,8 +34,8 @@ import fr.acinq.eclair.payment.send.MultiPartPaymentLifecycle.SendMultiPartPayme import fr.acinq.eclair.payment.send.PaymentError.UnsupportedFeatures import fr.acinq.eclair.payment.send.PaymentInitiator._ import fr.acinq.eclair.payment.send._ +import fr.acinq.eclair.router.BlindedRouteCreation import fr.acinq.eclair.router.Router._ -import fr.acinq.eclair.router.{BlindedRouteCreation, RouteNotFound} import fr.acinq.eclair.wire.protocol.OfferTypes.{InvoiceRequest, Offer} import fr.acinq.eclair.wire.protocol._ import fr.acinq.eclair.{Bolt11Feature, Bolt12Feature, CltvExpiry, CltvExpiryDelta, EncodedNodeId, Feature, Features, MilliSatoshiLong, NodeParams, PaymentFinalExpiryConf, TestConstants, TestKitBaseClass, TimestampSecond, UnknownFeature, randomBytes32, randomKey} @@ -57,7 +57,7 @@ class PaymentInitiatorSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike val RandomizeFinalExpiry = "random_final_expiry" } - case class FixtureParam(nodeParams: NodeParams, initiator: TestActorRef[PaymentInitiator], payFsm: TestProbe, multiPartPayFsm: TestProbe, sender: TestProbe, eventListener: TestProbe) + case class FixtureParam(nodeParams: NodeParams, initiator: TestActorRef[PaymentInitiator], payFsm: TestProbe, trampolinePayFsm: TestProbe, multiPartPayFsm: TestProbe, sender: TestProbe, eventListener: TestProbe) val featuresWithoutMpp: Features[Bolt11Feature] = Features( VariableLengthOnion -> Mandatory, @@ -77,7 +77,7 @@ class PaymentInitiatorSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike TrampolinePaymentPrototype -> Optional, ) - case class FakePaymentFactory(payFsm: TestProbe, multiPartPayFsm: TestProbe) extends PaymentInitiator.MultiPartPaymentFactory { + case class FakePaymentFactory(payFsm: TestProbe, trampolinePayFsm: TestProbe, multiPartPayFsm: TestProbe) extends PaymentInitiator.MultiPartPaymentFactory { // @formatter:off override def spawnOutgoingPayment(context: ActorContext, cfg: SendPaymentConfig): ActorRef = { payFsm.ref ! cfg @@ -87,6 +87,7 @@ class PaymentInitiatorSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike multiPartPayFsm.ref ! cfg multiPartPayFsm.ref } + override def spawnOutgoingTrampolinePayment(context: ActorContext): typed.ActorRef[TrampolinePaymentLifecycle.Command] = trampolinePayFsm.ref.toTyped // @formatter:on } @@ -102,11 +103,11 @@ class PaymentInitiatorSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike PaymentFinalExpiryConf(CltvExpiryDelta(1), CltvExpiryDelta(1)) } val nodeParams = TestConstants.Alice.nodeParams.copy(features = features.unscoped(), paymentFinalExpiry = paymentFinalExpiry) - val (sender, payFsm, multiPartPayFsm) = (TestProbe(), TestProbe(), TestProbe()) + val (sender, payFsm, trampolinePayFsm, multiPartPayFsm) = (TestProbe(), TestProbe(), TestProbe(), TestProbe()) val eventListener = TestProbe() system.eventStream.subscribe(eventListener.ref, classOf[PaymentEvent]) - val initiator = TestActorRef(new PaymentInitiator(nodeParams, FakePaymentFactory(payFsm, multiPartPayFsm))) - withFixture(test.toNoArgTest(FixtureParam(nodeParams, initiator, payFsm, multiPartPayFsm, sender, eventListener))) + val initiator = TestActorRef(new PaymentInitiator(nodeParams, FakePaymentFactory(payFsm, trampolinePayFsm, multiPartPayFsm))) + withFixture(test.toNoArgTest(FixtureParam(nodeParams, initiator, payFsm, trampolinePayFsm, multiPartPayFsm, sender, eventListener))) } test("forward payment with user custom tlv records") { f => @@ -171,7 +172,7 @@ class PaymentInitiatorSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike val finalExpiryDelta = CltvExpiryDelta(36) val invoice = Bolt11Invoice(Block.LivenetGenesisBlock.hash, Some(finalAmount), paymentHash, priv_c.privateKey, Left("Some invoice"), finalExpiryDelta) val route = PredefinedNodeRoute(finalAmount, Seq(a, b, c)) - val request = SendPaymentToRoute(finalAmount, invoice, Nil, route, None, None, None) + val request = SendPaymentToRoute(finalAmount, invoice, Nil, route, None, None) sender.send(initiator, request) val payment = sender.expectMsgType[SendPaymentToRouteResponse] payFsm.expectMsg(SendPaymentConfig(payment.paymentId, payment.parentId, None, paymentHash, c, Upstream.Local(payment.paymentId), Some(invoice), None, storeInDb = true, publishEvent = true, recordPathFindingMetrics = false, confidence = 1.0)) @@ -257,7 +258,7 @@ class PaymentInitiatorSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike import f._ val invoice = Bolt11Invoice(Block.LivenetGenesisBlock.hash, Some(finalAmount), paymentHash, priv_c.privateKey, Left("Some invoice"), CltvExpiryDelta(18), features = featuresWithMpp) val route = PredefinedChannelRoute(finalAmount / 2, c, Seq(channelUpdate_ab.shortChannelId, channelUpdate_bc.shortChannelId)) - val req = SendPaymentToRoute(finalAmount, invoice, Nil, route, None, None, None) + val req = SendPaymentToRoute(finalAmount, invoice, Nil, route, None, None) sender.send(initiator, req) val payment = sender.expectMsgType[SendPaymentToRouteResponse] payFsm.expectMsg(SendPaymentConfig(payment.paymentId, payment.parentId, None, paymentHash, c, Upstream.Local(payment.paymentId), Some(invoice), None, storeInDb = true, publishEvent = true, recordPathFindingMetrics = false, confidence = 1.0)) @@ -362,50 +363,24 @@ class PaymentInitiatorSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike import f._ val ignoredRoutingHints = List(List(ExtraHop(b, channelUpdate_bc.shortChannelId, feeBase = 10 msat, feeProportionalMillionths = 1, cltvExpiryDelta = CltvExpiryDelta(12)))) val invoice = Bolt11Invoice(Block.LivenetGenesisBlock.hash, Some(finalAmount), paymentHash, priv_c.privateKey, Left("Some phoenix invoice"), CltvExpiryDelta(9), features = featuresWithTrampoline, extraHops = ignoredRoutingHints) - val trampolineFees = 21_000 msat - val req = SendTrampolinePayment(sender.ref, finalAmount, invoice, b, Seq((trampolineFees, CltvExpiryDelta(12))), nodeParams.routerConf.pathFindingExperimentConf.getRandomConf().getDefaultRouteParams) + val req = SendTrampolinePayment(sender.ref, invoice, b, nodeParams.routerConf.pathFindingExperimentConf.getRandomConf().getDefaultRouteParams) sender.send(initiator, req) val id = sender.expectMsgType[UUID] - multiPartPayFsm.expectMsgType[SendPaymentConfig] + assert(trampolinePayFsm.expectMsgType[TrampolinePaymentLifecycle.SendPayment].invoice == invoice) sender.send(initiator, GetPayment(PaymentIdentifier.PaymentUUID(id))) - sender.expectMsg(PaymentIsPending(id, invoice.paymentHash, PendingTrampolinePayment(sender.ref, Nil, req))) + sender.expectMsg(PaymentIsPending(id, invoice.paymentHash, PendingTrampolinePayment(sender.ref, req))) sender.send(initiator, GetPayment(PaymentIdentifier.PaymentHash(invoice.paymentHash))) - sender.expectMsg(PaymentIsPending(id, invoice.paymentHash, PendingTrampolinePayment(sender.ref, Nil, req))) - - val msg = multiPartPayFsm.expectMsgType[SendMultiPartPayment] - assert(msg.recipient.nodeId == c) - assert(msg.recipient.totalAmount == finalAmount) - assert(msg.recipient.expiry.toLong == currentBlockCount + 9 + 1) - assert(msg.recipient.features.hasFeature(Features.TrampolinePaymentPrototype)) - assert(msg.recipient.isInstanceOf[TrampolineRecipient]) - assert(msg.recipient.asInstanceOf[TrampolineRecipient].trampolineNodeId == b) - assert(msg.recipient.asInstanceOf[TrampolineRecipient].trampolineAmount == finalAmount + trampolineFees) - assert(msg.recipient.asInstanceOf[TrampolineRecipient].trampolineExpiry == CltvExpiry(currentBlockCount + 9 + 1 + 12)) - assert(msg.recipient.asInstanceOf[TrampolineRecipient].trampolinePaymentSecret != invoice.paymentSecret) // we should not leak the invoice secret to the trampoline node - assert(msg.maxAttempts == nodeParams.maxPaymentAttempts) + sender.expectMsg(PaymentIsPending(id, invoice.paymentHash, PendingTrampolinePayment(sender.ref, req))) } test("forward trampoline to legacy payment") { f => import f._ val invoice = Bolt11Invoice(Block.LivenetGenesisBlock.hash, Some(finalAmount), paymentHash, priv_c.privateKey, Left("Some wallet invoice"), CltvExpiryDelta(9)) - val trampolineFees = 21_000 msat - val req = SendTrampolinePayment(sender.ref, finalAmount, invoice, b, Seq((trampolineFees, CltvExpiryDelta(12))), routeParams = nodeParams.routerConf.pathFindingExperimentConf.getRandomConf().getDefaultRouteParams) + val req = SendTrampolinePayment(sender.ref, invoice, b, routeParams = nodeParams.routerConf.pathFindingExperimentConf.getRandomConf().getDefaultRouteParams) sender.send(initiator, req) sender.expectMsgType[UUID] - multiPartPayFsm.expectMsgType[SendPaymentConfig] - - val msg = multiPartPayFsm.expectMsgType[SendMultiPartPayment] - assert(msg.recipient.nodeId == c) - assert(msg.recipient.totalAmount == finalAmount) - assert(msg.recipient.expiry.toLong == currentBlockCount + 9 + 1) - assert(!msg.recipient.features.hasFeature(Features.TrampolinePaymentPrototype)) - assert(msg.recipient.isInstanceOf[TrampolineRecipient]) - assert(msg.recipient.asInstanceOf[TrampolineRecipient].trampolineNodeId == b) - assert(msg.recipient.asInstanceOf[TrampolineRecipient].trampolineAmount == finalAmount + trampolineFees) - assert(msg.recipient.asInstanceOf[TrampolineRecipient].trampolineExpiry == CltvExpiry(currentBlockCount + 9 + 1 + 12)) - assert(msg.recipient.asInstanceOf[TrampolineRecipient].trampolinePaymentSecret != invoice.paymentSecret) // we should not leak the invoice secret to the trampoline node - assert(msg.maxAttempts == nodeParams.maxPaymentAttempts) + assert(trampolinePayFsm.expectMsgType[TrampolinePaymentLifecycle.SendPayment].invoice == invoice) } test("reject trampoline to legacy payment for 0-value invoice") { f => @@ -413,130 +388,13 @@ class PaymentInitiatorSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike // This is disabled because it would let the trampoline node steal the whole payment (if malicious). val routingHints = List(List(Bolt11Invoice.ExtraHop(b, channelUpdate_bc.shortChannelId, 10 msat, 100, CltvExpiryDelta(144)))) val invoice = Bolt11Invoice(Block.RegtestGenesisBlock.hash, None, paymentHash, priv_a.privateKey, Left("#abittooreckless"), CltvExpiryDelta(18), None, None, routingHints, features = featuresWithMpp) - val trampolineFees = 21_000 msat - val req = SendTrampolinePayment(sender.ref, finalAmount, invoice, b, Seq((trampolineFees, CltvExpiryDelta(12))), routeParams = nodeParams.routerConf.pathFindingExperimentConf.getRandomConf().getDefaultRouteParams) + val req = SendTrampolinePayment(sender.ref, invoice, b, routeParams = nodeParams.routerConf.pathFindingExperimentConf.getRandomConf().getDefaultRouteParams) sender.send(initiator, req) val id = sender.expectMsgType[UUID] val fail = sender.expectMsgType[PaymentFailed] assert(fail.id == id) - assert(fail.failures == LocalFailure(finalAmount, Nil, PaymentError.TrampolineLegacyAmountLessInvoice) :: Nil) - - multiPartPayFsm.expectNoMessage(50 millis) - payFsm.expectNoMessage(50 millis) - } - - test("retry trampoline payment") { f => - import f._ - val invoice = Bolt11Invoice(Block.LivenetGenesisBlock.hash, Some(finalAmount), paymentHash, priv_c.privateKey, Left("Some phoenix invoice"), CltvExpiryDelta(18), features = featuresWithTrampoline) - val trampolineAttempts = (21_000 msat, CltvExpiryDelta(12)) :: (25_000 msat, CltvExpiryDelta(24)) :: Nil - val req = SendTrampolinePayment(sender.ref, finalAmount, invoice, b, trampolineAttempts, routeParams = nodeParams.routerConf.pathFindingExperimentConf.getRandomConf().getDefaultRouteParams) - sender.send(initiator, req) - val id = sender.expectMsgType[UUID] - val cfg = multiPartPayFsm.expectMsgType[SendPaymentConfig] - assert(cfg.storeInDb) - assert(!cfg.publishEvent) - - val msg1 = multiPartPayFsm.expectMsgType[SendMultiPartPayment] - assert(msg1.recipient.totalAmount == finalAmount) - assert(msg1.recipient.asInstanceOf[TrampolineRecipient].trampolineAmount == finalAmount + 21_000.msat) - - sender.send(initiator, GetPayment(PaymentIdentifier.PaymentUUID(id))) - sender.expectMsgType[PaymentIsPending] - - // Simulate a failure which should trigger a retry. - multiPartPayFsm.send(initiator, PaymentFailed(cfg.parentId, invoice.paymentHash, Seq(RemoteFailure(msg1.recipient.totalAmount, Nil, Sphinx.DecryptedFailurePacket(b, TrampolineFeeInsufficient()))))) - multiPartPayFsm.expectMsgType[SendPaymentConfig] - val msg2 = multiPartPayFsm.expectMsgType[SendMultiPartPayment] - assert(msg2.recipient.totalAmount == finalAmount) - assert(msg2.recipient.asInstanceOf[TrampolineRecipient].trampolineAmount == finalAmount + 25_000.msat) - - // Simulate success which should publish the event and respond to the original sender. - val success = PaymentSent(cfg.parentId, invoice.paymentHash, randomBytes32(), finalAmount, c, Seq(PaymentSent.PartialPayment(UUID.randomUUID(), 1000 msat, 500 msat, randomBytes32(), None))) - multiPartPayFsm.send(initiator, success) - sender.expectMsg(success) - eventListener.expectMsg(success) - sender.expectNoMessage(100 millis) - eventListener.expectNoMessage(100 millis) - - sender.send(initiator, GetPayment(PaymentIdentifier.PaymentUUID(id))) - sender.expectMsg(NoPendingPayment(PaymentIdentifier.PaymentUUID(id))) - } - - test("retry trampoline payment and fail") { f => - import f._ - val invoice = Bolt11Invoice(Block.LivenetGenesisBlock.hash, Some(finalAmount), paymentHash, priv_c.privateKey, Left("Some phoenix invoice"), CltvExpiryDelta(18), features = featuresWithTrampoline) - val trampolineAttempts = (21_000 msat, CltvExpiryDelta(12)) :: (25_000 msat, CltvExpiryDelta(24)) :: Nil - val req = SendTrampolinePayment(sender.ref, finalAmount, invoice, b, trampolineAttempts, routeParams = nodeParams.routerConf.pathFindingExperimentConf.getRandomConf().getDefaultRouteParams) - sender.send(initiator, req) - sender.expectMsgType[UUID] - val cfg = multiPartPayFsm.expectMsgType[SendPaymentConfig] - assert(cfg.storeInDb) - assert(!cfg.publishEvent) - - val msg1 = multiPartPayFsm.expectMsgType[SendMultiPartPayment] - assert(msg1.recipient.totalAmount == finalAmount) - assert(msg1.recipient.asInstanceOf[TrampolineRecipient].trampolineAmount == finalAmount + 21_000.msat) - - // Simulate a failure which should trigger a retry. - multiPartPayFsm.send(initiator, PaymentFailed(cfg.parentId, invoice.paymentHash, Seq(RemoteFailure(msg1.recipient.totalAmount, Nil, Sphinx.DecryptedFailurePacket(b, TrampolineFeeInsufficient()))))) - multiPartPayFsm.expectMsgType[SendPaymentConfig] - val msg2 = multiPartPayFsm.expectMsgType[SendMultiPartPayment] - assert(msg2.recipient.totalAmount == finalAmount) - assert(msg2.recipient.asInstanceOf[TrampolineRecipient].trampolineAmount == finalAmount + 25_000.msat) - - // Simulate a failure that exhausts payment attempts. - val failed = PaymentFailed(cfg.parentId, invoice.paymentHash, Seq(RemoteFailure(msg2.recipient.totalAmount, Nil, Sphinx.DecryptedFailurePacket(b, TemporaryNodeFailure())))) - multiPartPayFsm.send(initiator, failed) - sender.expectMsg(failed) - eventListener.expectMsg(failed) - sender.expectNoMessage(100 millis) - eventListener.expectNoMessage(100 millis) - } - - test("retry trampoline payment and fail (route not found)") { f => - import f._ - val invoice = Bolt11Invoice(Block.LivenetGenesisBlock.hash, Some(finalAmount), paymentHash, priv_c.privateKey, Left("Some phoenix invoice"), CltvExpiryDelta(18), features = featuresWithTrampoline) - val trampolineAttempts = (21_000 msat, CltvExpiryDelta(12)) :: (25_000 msat, CltvExpiryDelta(24)) :: Nil - val req = SendTrampolinePayment(sender.ref, finalAmount, invoice, b, trampolineAttempts, routeParams = nodeParams.routerConf.pathFindingExperimentConf.getRandomConf().getDefaultRouteParams) - sender.send(initiator, req) - sender.expectMsgType[UUID] - - val cfg = multiPartPayFsm.expectMsgType[SendPaymentConfig] - val msg1 = multiPartPayFsm.expectMsgType[SendMultiPartPayment] - assert(msg1.recipient.asInstanceOf[TrampolineRecipient].trampolineAmount == finalAmount + 21_000.msat) - // Trampoline node couldn't find a route for the given fee. - val failed = PaymentFailed(cfg.parentId, invoice.paymentHash, Seq(RemoteFailure(msg1.recipient.totalAmount, Nil, Sphinx.DecryptedFailurePacket(b, TrampolineFeeInsufficient())))) - multiPartPayFsm.send(initiator, failed) - multiPartPayFsm.expectMsgType[SendPaymentConfig] - val msg2 = multiPartPayFsm.expectMsgType[SendMultiPartPayment] - assert(msg2.recipient.asInstanceOf[TrampolineRecipient].trampolineAmount == finalAmount + 25_000.msat) - // Trampoline node couldn't find a route even with the increased fee. - multiPartPayFsm.send(initiator, failed) - - val failure = sender.expectMsgType[PaymentFailed] - assert(failure.failures == Seq(LocalFailure(finalAmount, Seq(NodeHop(b, c, CltvExpiryDelta(24), 25_000 msat)), RouteNotFound))) - eventListener.expectMsg(failure) - sender.expectNoMessage(100 millis) - eventListener.expectNoMessage(100 millis) - } - - test("forward trampoline payment with pre-defined route") { f => - import f._ - val invoice = Bolt11Invoice(Block.LivenetGenesisBlock.hash, Some(finalAmount), paymentHash, priv_c.privateKey, Left("Some invoice"), CltvExpiryDelta(18)) - val trampolineAttempt = TrampolineAttempt(randomBytes32(), 100 msat, CltvExpiryDelta(144)) - val route = PredefinedNodeRoute(finalAmount + trampolineAttempt.fees, Seq(a, b)) - val req = SendPaymentToRoute(finalAmount, invoice, Nil, route, None, None, Some(trampolineAttempt)) - sender.send(initiator, req) - val payment = sender.expectMsgType[SendPaymentToRouteResponse] - assert(payment.trampolineSecret.contains(trampolineAttempt.paymentSecret)) - payFsm.expectMsg(SendPaymentConfig(payment.paymentId, payment.parentId, None, paymentHash, c, Upstream.Local(payment.paymentId), Some(invoice), None, storeInDb = true, publishEvent = true, recordPathFindingMetrics = false, confidence = 1.0)) - val msg = payFsm.expectMsgType[PaymentLifecycle.SendPaymentToRoute] - assert(msg.route == Left(route)) - assert(msg.amount == finalAmount + trampolineAttempt.fees) - assert(msg.recipient.totalAmount == finalAmount) - assert(msg.recipient.isInstanceOf[TrampolineRecipient]) - assert(msg.recipient.asInstanceOf[TrampolineRecipient].trampolineAmount == finalAmount + trampolineAttempt.fees) - assert(msg.recipient.asInstanceOf[TrampolineRecipient].trampolinePaymentSecret == payment.trampolineSecret.get) + assert(fail.failures.head.isInstanceOf[LocalFailure]) + trampolinePayFsm.expectNoMessage(50 millis) } } 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 e8174b4cff..016c03bce9 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 @@ -28,7 +28,7 @@ import fr.acinq.eclair.crypto.{ShaChain, Sphinx} import fr.acinq.eclair.payment.IncomingPaymentPacket.{ChannelRelayPacket, FinalPacket, RelayToTrampolinePacket, decrypt} import fr.acinq.eclair.payment.OutgoingPaymentPacket._ import fr.acinq.eclair.payment.send.BlindedPathsResolver.{FullBlindedRoute, ResolvedPath} -import fr.acinq.eclair.payment.send.{BlindedRecipient, ClearRecipient, TrampolineRecipient} +import fr.acinq.eclair.payment.send.{BlindedRecipient, ClearRecipient, TrampolinePayment} import fr.acinq.eclair.router.BaseRouterSpec.{blindedRouteFromHops, channelHopFromUpdate} import fr.acinq.eclair.router.BlindedRouteCreation import fr.acinq.eclair.router.Router.{NodeHop, Route} @@ -280,30 +280,19 @@ class PaymentPacketSpec extends AnyFunSuite with BeforeAndAfterAll { test("build outgoing trampoline payment") { // simple trampoline route to e: - // .----. - // / \ - // a -> b -> c e + // .----. + // / \ + // b -> c e val invoiceFeatures = Features[Bolt11Feature](VariableLengthOnion -> Mandatory, PaymentSecret -> Mandatory, BasicMultiPartPayment -> Optional, PaymentMetadata -> Optional, TrampolinePaymentPrototype -> Optional) - val invoice = Bolt11Invoice(Block.RegtestGenesisBlock.hash, None, paymentHash, priv_e.privateKey, Left("invoice"), CltvExpiryDelta(6), paymentSecret = paymentSecret, features = invoiceFeatures, paymentMetadata = Some(hex"010203")) - val recipient = TrampolineRecipient(invoice, finalAmount, finalExpiry, trampolineHop, randomBytes32()) - assert(recipient.trampolineAmount == amount_bc) - assert(recipient.trampolineExpiry == expiry_bc) - val Right(payment) = buildOutgoingPayment(TestConstants.emptyOrigin, paymentHash, Route(recipient.trampolineAmount, trampolineChannelHops, Some(trampolineHop)), recipient, 1.0) - assert(payment.outgoingChannel == channelUpdate_ab.shortChannelId) - assert(payment.cmd.amount == amount_ab) - assert(payment.cmd.cltvExpiry == expiry_ab) + val invoice = Bolt11Invoice(Block.RegtestGenesisBlock.hash, Some(finalAmount), paymentHash, priv_e.privateKey, Left("invoice"), CltvExpiryDelta(6), paymentSecret = paymentSecret, features = invoiceFeatures, paymentMetadata = Some(hex"010203")) + val payment = TrampolinePayment.buildOutgoingPayment(c, invoice, finalExpiry) - val add_b = UpdateAddHtlc(randomBytes32(), 1, payment.cmd.amount, paymentHash, payment.cmd.cltvExpiry, payment.cmd.onion, None, 1.0, None) - val Right(ChannelRelayPacket(add_b2, payload_b, packet_c)) = decrypt(add_b, priv_b.privateKey, Features.empty) - assert(add_b2 == add_b) - assert(payload_b == IntermediatePayload.ChannelRelay.Standard(channelUpdate_bc.shortChannelId, amount_bc, expiry_bc)) - - val add_c = UpdateAddHtlc(randomBytes32(), 2, amount_bc, paymentHash, expiry_bc, packet_c, None, 1.0, None) + val add_c = UpdateAddHtlc(randomBytes32(), 2, payment.trampolineAmount, paymentHash, payment.trampolineExpiry, payment.onion.packet, None, 1.0, None) val Right(RelayToTrampolinePacket(add_c2, outer_c, inner_c, trampolinePacket_e)) = decrypt(add_c, priv_c.privateKey, Features.empty) assert(add_c2 == add_c) - assert(outer_c.amount == amount_bc) - assert(outer_c.totalAmount == amount_bc) - assert(outer_c.expiry == expiry_bc) + assert(outer_c.amount == payment.trampolineAmount) + assert(outer_c.totalAmount == payment.trampolineAmount) + assert(outer_c.expiry == payment.trampolineExpiry) assert(outer_c.paymentSecret != invoice.paymentSecret) assert(inner_c.amountToForward == finalAmount) assert(inner_c.outgoingCltv == finalExpiry) @@ -332,28 +321,19 @@ class PaymentPacketSpec extends AnyFunSuite with BeforeAndAfterAll { test("build outgoing trampoline payment with non-trampoline recipient") { // simple trampoline route to e where e doesn't support trampoline: - // .----. - // / \ - // a -> b -> c e + // .----. + // / \ + // b -> c e val routingHints = List(List(Bolt11Invoice.ExtraHop(randomKey().publicKey, ShortChannelId(42), 10 msat, 100, CltvExpiryDelta(144)))) val invoiceFeatures = Features[Bolt11Feature](VariableLengthOnion -> Mandatory, PaymentSecret -> Mandatory, BasicMultiPartPayment -> Optional) val invoice = Bolt11Invoice(Block.RegtestGenesisBlock.hash, Some(finalAmount), paymentHash, priv_e.privateKey, Left("#reckless"), CltvExpiryDelta(18), extraHops = routingHints, features = invoiceFeatures, paymentMetadata = Some(hex"010203")) - val recipient = TrampolineRecipient(invoice, finalAmount, finalExpiry, trampolineHop, randomBytes32()) - assert(recipient.trampolineAmount == amount_bc) - assert(recipient.trampolineExpiry == expiry_bc) - val Right(payment) = buildOutgoingPayment(TestConstants.emptyOrigin, paymentHash, Route(recipient.trampolineAmount, trampolineChannelHops, Some(trampolineHop)), recipient, 1.0) - assert(payment.outgoingChannel == channelUpdate_ab.shortChannelId) - assert(payment.cmd.amount == amount_ab) - assert(payment.cmd.cltvExpiry == expiry_ab) - - val add_b = UpdateAddHtlc(randomBytes32(), 1, payment.cmd.amount, paymentHash, payment.cmd.cltvExpiry, payment.cmd.onion, None, 1.0, None) - val Right(ChannelRelayPacket(_, _, packet_c)) = decrypt(add_b, priv_b.privateKey, Features.empty) + val payment = TrampolinePayment.buildOutgoingPayment(c, invoice, finalExpiry) - val add_c = UpdateAddHtlc(randomBytes32(), 2, amount_bc, paymentHash, expiry_bc, packet_c, None, 1.0, None) + val add_c = UpdateAddHtlc(randomBytes32(), 2, payment.trampolineAmount, paymentHash, payment.trampolineExpiry, payment.onion.packet, None, 1.0, None) val Right(RelayToTrampolinePacket(_, outer_c, inner_c, _)) = decrypt(add_c, priv_c.privateKey, Features.empty) - assert(outer_c.amount == amount_bc) - assert(outer_c.totalAmount == amount_bc) - assert(outer_c.expiry == expiry_bc) + assert(outer_c.amount == payment.trampolineAmount) + assert(outer_c.totalAmount == payment.trampolineAmount) + assert(outer_c.expiry == payment.trampolineExpiry) assert(outer_c.paymentSecret != invoice.paymentSecret) assert(outer_c.records.get[OnionPaymentPayloadTlv.TrampolineOnion].get.packet.payload.size < 400) assert(inner_c.amountToForward == finalAmount) @@ -382,45 +362,6 @@ class PaymentPacketSpec extends AnyFunSuite with BeforeAndAfterAll { assert(payload_e == FinalPayload.Standard(TlvStream(AmountToForward(finalAmount), OutgoingCltv(finalExpiry), PaymentData(invoice.paymentSecret, finalAmount), OnionPaymentPayloadTlv.PaymentMetadata(hex"010203")))) } - test("build outgoing trampoline payment with non-trampoline recipient (large invoice data)") { - // simple trampoline route to e where e doesn't support trampoline: - // .----. - // / \ - // a -> b -> c e - // e provides many routing hints and a lot of payment metadata. - val routingHints = List(List.fill(7)(Bolt11Invoice.ExtraHop(randomKey().publicKey, ShortChannelId(1), 10 msat, 100, CltvExpiryDelta(12)))) - val paymentMetadata = ByteVector.fromValidHex("2a" * 450) - val invoiceFeatures = Features[Bolt11Feature](VariableLengthOnion -> Mandatory, PaymentSecret -> Mandatory, BasicMultiPartPayment -> Optional) - val invoice = Bolt11Invoice(Block.RegtestGenesisBlock.hash, Some(finalAmount), paymentHash, priv_e.privateKey, Left("#reckless"), CltvExpiryDelta(18), extraHops = routingHints, features = invoiceFeatures, paymentMetadata = Some(paymentMetadata)) - val recipient = TrampolineRecipient(invoice, finalAmount, finalExpiry, trampolineHop, randomBytes32()) - val Right(payment) = buildOutgoingPayment(TestConstants.emptyOrigin, paymentHash, Route(recipient.trampolineAmount, trampolineChannelHops, Some(trampolineHop)), recipient, 1.0) - assert(payment.outgoingChannel == channelUpdate_ab.shortChannelId) - - val add_b = UpdateAddHtlc(randomBytes32(), 1, payment.cmd.amount, paymentHash, payment.cmd.cltvExpiry, payment.cmd.onion, None, 1.0, None) - val Right(ChannelRelayPacket(_, _, packet_c)) = decrypt(add_b, priv_b.privateKey, Features.empty) - - val add_c = UpdateAddHtlc(randomBytes32(), 2, amount_bc, paymentHash, expiry_bc, packet_c, None, 1.0, None) - val Right(RelayToTrampolinePacket(_, outer_c, inner_c, _)) = decrypt(add_c, priv_c.privateKey, Features.empty) - assert(outer_c.records.get[OnionPaymentPayloadTlv.TrampolineOnion].get.packet.payload.size > 800) - assert(inner_c.outgoingNodeId == e) - assert(inner_c.paymentMetadata.contains(paymentMetadata)) - assert(inner_c.invoiceRoutingInfo.contains(routingHints)) - - // c forwards the trampoline payment to e through d. - val recipient_e = ClearRecipient(e, Features.empty, inner_c.amountToForward, inner_c.outgoingCltv, inner_c.paymentSecret.get, invoice.extraEdges, inner_c.paymentMetadata) - val Right(payment_e) = buildOutgoingPayment(Origin.Hot(ActorRef.noSender, Upstream.Hot.Trampoline(List(Upstream.Hot.Channel(add_c, TimestampMilli(1687345927000L), b)))), paymentHash, Route(inner_c.amountToForward, afterTrampolineChannelHops, None), recipient_e, 1.0) - assert(payment_e.outgoingChannel == channelUpdate_cd.shortChannelId) - val add_d = UpdateAddHtlc(randomBytes32(), 3, payment_e.cmd.amount, paymentHash, payment_e.cmd.cltvExpiry, payment_e.cmd.onion, None, 1.0, None) - val Right(ChannelRelayPacket(add_d2, payload_d, packet_e)) = decrypt(add_d, priv_d.privateKey, Features.empty) - assert(add_d2 == add_d) - assert(payload_d == IntermediatePayload.ChannelRelay.Standard(channelUpdate_de.shortChannelId, amount_de, expiry_de)) - - val add_e = UpdateAddHtlc(randomBytes32(), 4, amount_de, paymentHash, expiry_de, packet_e, None, 1.0, None) - val Right(FinalPacket(add_e2, payload_e)) = decrypt(add_e, priv_e.privateKey, Features.empty) - assert(add_e2 == add_e) - assert(payload_e == FinalPayload.Standard(TlvStream(AmountToForward(finalAmount), OutgoingCltv(finalExpiry), PaymentData(invoice.paymentSecret, finalAmount), OnionPaymentPayloadTlv.PaymentMetadata(paymentMetadata)))) - } - test("fail to build outgoing payment with invalid route") { val recipient = ClearRecipient(e, Features.empty, finalAmount, finalExpiry, paymentSecret) val route = Route(finalAmount, hops.dropRight(1), None) // route doesn't reach e @@ -428,15 +369,6 @@ class PaymentPacketSpec extends AnyFunSuite with BeforeAndAfterAll { assert(failure == InvalidRouteRecipient(e, d)) } - test("fail to build outgoing trampoline payment with invalid route") { - val invoiceFeatures = Features[Bolt11Feature](VariableLengthOnion -> Mandatory, PaymentSecret -> Mandatory, BasicMultiPartPayment -> Optional, PaymentMetadata -> Optional, TrampolinePaymentPrototype -> Optional) - val invoice = Bolt11Invoice(Block.RegtestGenesisBlock.hash, None, paymentHash, priv_e.privateKey, Left("invoice"), CltvExpiryDelta(6), paymentSecret = paymentSecret, features = invoiceFeatures) - val recipient = TrampolineRecipient(invoice, finalAmount, finalExpiry, trampolineHop, randomBytes32()) - val route = Route(finalAmount, trampolineChannelHops, None) // missing trampoline hop - val Left(failure) = buildOutgoingPayment(TestConstants.emptyOrigin, paymentHash, route, recipient, 1.0) - assert(failure == MissingTrampolineHop(c)) - } - test("fail to build outgoing blinded payment with invalid route") { val (_, route, recipient) = longBlindedHops(hex"deadbeef") assert(buildOutgoingPayment(TestConstants.emptyOrigin, paymentHash, route, recipient, 1.0).isRight) @@ -455,14 +387,10 @@ class PaymentPacketSpec extends AnyFunSuite with BeforeAndAfterAll { test("fail to decrypt when the trampoline onion is invalid") { val invoiceFeatures = Features[Bolt11Feature](VariableLengthOnion -> Mandatory, PaymentSecret -> Mandatory, BasicMultiPartPayment -> Optional, PaymentMetadata -> Optional, TrampolinePaymentPrototype -> Optional) - val invoice = Bolt11Invoice(Block.RegtestGenesisBlock.hash, None, paymentHash, priv_e.privateKey, Left("invoice"), CltvExpiryDelta(6), paymentSecret = paymentSecret, features = invoiceFeatures, paymentMetadata = Some(hex"010203")) - val recipient = TrampolineRecipient(invoice, finalAmount, finalExpiry, trampolineHop, randomBytes32()) - val Right(payment) = buildOutgoingPayment(TestConstants.emptyOrigin, paymentHash, Route(recipient.trampolineAmount, trampolineChannelHops, Some(trampolineHop)), recipient, 1.0) + val invoice = Bolt11Invoice(Block.RegtestGenesisBlock.hash, Some(finalAmount), paymentHash, priv_e.privateKey, Left("invoice"), CltvExpiryDelta(6), paymentSecret = paymentSecret, features = invoiceFeatures, paymentMetadata = Some(hex"010203")) + val payment = TrampolinePayment.buildOutgoingPayment(c, invoice, finalExpiry) - val add_b = UpdateAddHtlc(randomBytes32(), 1, payment.cmd.amount, paymentHash, payment.cmd.cltvExpiry, payment.cmd.onion, None, 1.0, None) - val Right(ChannelRelayPacket(_, _, packet_c)) = decrypt(add_b, priv_b.privateKey, Features.empty) - - val add_c = UpdateAddHtlc(randomBytes32(), 2, amount_bc, paymentHash, expiry_bc, packet_c, None, 1.0, None) + val add_c = UpdateAddHtlc(randomBytes32(), 2, payment.trampolineAmount, paymentHash, payment.trampolineExpiry, payment.onion.packet, None, 1.0, None) val Right(RelayToTrampolinePacket(_, _, inner_c, trampolinePacket_e)) = decrypt(add_c, priv_c.privateKey, Features.empty) // c forwards an invalid trampoline onion to e through d. @@ -593,21 +521,16 @@ class PaymentPacketSpec extends AnyFunSuite with BeforeAndAfterAll { } // Create a trampoline payment to e: - // .----. - // / \ - // a -> b -> c e + // .----. + // / \ + // b -> c e // // and return the HTLC sent by b to c. def createIntermediateTrampolinePayment(): UpdateAddHtlc = { val invoiceFeatures = Features[Bolt11Feature](VariableLengthOnion -> Mandatory, PaymentSecret -> Mandatory, BasicMultiPartPayment -> Optional, TrampolinePaymentPrototype -> Optional) - val invoice = Bolt11Invoice(Block.RegtestGenesisBlock.hash, None, paymentHash, priv_e.privateKey, Left("invoice"), CltvExpiryDelta(6), paymentSecret = paymentSecret, features = invoiceFeatures) - val recipient = TrampolineRecipient(invoice, finalAmount, finalExpiry, trampolineHop, randomBytes32()) - val Right(payment) = buildOutgoingPayment(TestConstants.emptyOrigin, paymentHash, Route(recipient.trampolineAmount, trampolineChannelHops, Some(trampolineHop)), recipient, 1.0) - - val add_b = UpdateAddHtlc(randomBytes32(), 1, payment.cmd.amount, paymentHash, payment.cmd.cltvExpiry, payment.cmd.onion, None, 1.0, None) - val Right(ChannelRelayPacket(_, _, packet_c)) = decrypt(add_b, priv_b.privateKey, Features.empty) - - UpdateAddHtlc(randomBytes32(), 2, amount_bc, paymentHash, expiry_bc, packet_c, None, 1.0, None) + val invoice = Bolt11Invoice(Block.RegtestGenesisBlock.hash, Some(finalAmount), paymentHash, priv_e.privateKey, Left("invoice"), CltvExpiryDelta(6), paymentSecret = paymentSecret, features = invoiceFeatures) + val payment = TrampolinePayment.buildOutgoingPayment(c, invoice, finalExpiry) + UpdateAddHtlc(randomBytes32(), 2, payment.trampolineAmount, paymentHash, payment.trampolineExpiry, payment.onion.packet, None, 1.0, None) } test("fail to decrypt at the final trampoline node when amount has been decreased by next-to-last trampoline") { @@ -654,7 +577,7 @@ class PaymentPacketSpec extends AnyFunSuite with BeforeAndAfterAll { val invalidAdd = add_c.copy(cltvExpiry = add_c.cltvExpiry - CltvExpiryDelta(12)) // A trampoline relay is very similar to a final node: it validates that the HTLC expiry matches the onion outer expiry. val Left(failure) = decrypt(invalidAdd, priv_c.privateKey, Features.empty) - assert(failure == FinalIncorrectCltvExpiry(expiry_bc - CltvExpiryDelta(12))) + assert(failure.isInstanceOf[FinalIncorrectCltvExpiry]) } test("build htlc failure onion") { diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/RelayerSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/RelayerSpec.scala index 2c22c743e6..8ad5769db4 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/RelayerSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/RelayerSpec.scala @@ -33,9 +33,9 @@ import fr.acinq.eclair.payment.IncomingPaymentPacket.FinalPacket import fr.acinq.eclair.payment.OutgoingPaymentPacket.{NodePayload, buildOnion, buildOutgoingPayment} import fr.acinq.eclair.payment.PaymentPacketSpec._ import fr.acinq.eclair.payment.relay.Relayer._ -import fr.acinq.eclair.payment.send.{ClearRecipient, TrampolineRecipient} +import fr.acinq.eclair.payment.send.{ClearRecipient, TrampolinePayment} import fr.acinq.eclair.router.BaseRouterSpec.{blindedRouteFromHops, channelHopFromUpdate} -import fr.acinq.eclair.router.Router.{NodeHop, Route} +import fr.acinq.eclair.router.Router.Route import fr.acinq.eclair.wire.protocol.PaymentOnion.FinalPayload import fr.acinq.eclair.wire.protocol._ import org.scalatest.concurrent.PatienceConfiguration @@ -193,13 +193,11 @@ class RelayerSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("applicat // we use this to build a valid trampoline onion inside a normal onion val invoiceFeatures = Features[Bolt11Feature](VariableLengthOnion -> Mandatory, PaymentSecret -> Mandatory, BasicMultiPartPayment -> Optional, PaymentMetadata -> Optional, TrampolinePaymentPrototype -> Optional) - val invoice = Bolt11Invoice(Block.RegtestGenesisBlock.hash, None, paymentHash, priv_c.privateKey, Left("invoice"), CltvExpiryDelta(6), paymentSecret = paymentSecret, features = invoiceFeatures) - val trampolineHop = NodeHop(b, c, channelUpdate_bc.cltvExpiryDelta, fee_b) - val recipient = TrampolineRecipient(invoice, finalAmount, finalExpiry, trampolineHop, randomBytes32()) - val Right(payment) = buildOutgoingPayment(TestConstants.emptyOrigin, paymentHash, Route(recipient.trampolineAmount, Seq(channelHopFromUpdate(priv_a.publicKey, b, channelUpdate_ab)), Some(trampolineHop)), recipient, 1.0) + val invoice = Bolt11Invoice(Block.RegtestGenesisBlock.hash, Some(finalAmount), paymentHash, priv_c.privateKey, Left("invoice"), CltvExpiryDelta(6), paymentSecret = paymentSecret, features = invoiceFeatures) + val payment = TrampolinePayment.buildOutgoingPayment(b, invoice, finalExpiry) // and then manually build an htlc - val add_ab = UpdateAddHtlc(channelId_ab, 123456, payment.cmd.amount, payment.cmd.paymentHash, payment.cmd.cltvExpiry, payment.cmd.onion, None, 1.0, None) + val add_ab = UpdateAddHtlc(channelId_ab, 123456, payment.trampolineAmount, invoice.paymentHash, payment.trampolineExpiry, payment.onion.packet, None, 1.0, None) relayer ! RelayForward(add_ab, priv_a.publicKey) val fail = register.expectMessageType[Register.Forward[CMD_FAIL_HTLC]].message diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/router/RouterSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/router/RouterSpec.scala index 89f79247b6..2cc58b6ca9 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/router/RouterSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/router/RouterSpec.scala @@ -29,7 +29,7 @@ import fr.acinq.eclair.crypto.TransportHandler import fr.acinq.eclair.io.Peer.PeerRoutingMessage import fr.acinq.eclair.payment.Bolt11Invoice.ExtraHop import fr.acinq.eclair.payment.Invoice.ExtraEdge -import fr.acinq.eclair.payment.send.{ClearRecipient, TrampolineRecipient, SpontaneousRecipient} +import fr.acinq.eclair.payment.send.{ClearRecipient, SpontaneousRecipient} import fr.acinq.eclair.payment.{Bolt11Invoice, Invoice} import fr.acinq.eclair.router.Announcements.{makeChannelUpdate, makeNodeAnnouncement} import fr.acinq.eclair.router.BaseRouterSpec.{blindedRoutesFromPaths, channelAnnouncement} @@ -508,34 +508,6 @@ class RouterSpec extends BaseRouterSpec { assert(route2.channelFee(false) == 10.msat) } - test("routes found (with trampoline hop)") { fixture => - import fixture._ - val sender = TestProbe() - val routeParams = DEFAULT_ROUTE_PARAMS.copy(boundaries = SearchBoundaries(25_015 msat, 0.0, 6, CltvExpiryDelta(1008))) - val recipientKey = randomKey() - val invoice = Bolt11Invoice(Block.RegtestGenesisBlock.hash, None, randomBytes32(), recipientKey, Left("invoice"), CltvExpiryDelta(6)) - val trampolineHop = NodeHop(c, recipientKey.publicKey, CltvExpiryDelta(100), 25_000 msat) - val recipient = TrampolineRecipient(invoice, 725_000 msat, DEFAULT_EXPIRY, trampolineHop, randomBytes32()) - sender.send(router, RouteRequest(a, recipient, routeParams)) - val route1 = sender.expectMsgType[RouteResponse].routes.head - assert(route1.amount == 750_000.msat) - assert(route2NodeIds(route1) == Seq(a, b, c)) - assert(route1.channelFee(false) == 10.msat) - assert(route1.trampolineFee == 25_000.msat) - assert(route1.finalHop_opt.contains(trampolineHop)) - // We can't find another route to complete the payment amount because it exceeds the fee budget. - sender.send(router, RouteRequest(a, recipient, routeParams, pendingPayments = Seq(route1.copy(500_000 msat)))) - sender.expectMsg(Failure(RouteNotFound)) - // But if we increase the fee budget, we're able to find a second route. - sender.send(router, RouteRequest(a, recipient, routeParams.copy(boundaries = routeParams.boundaries.copy(maxFeeFlat = 25_020 msat)), pendingPayments = Seq(route1.copy(500_000 msat)))) - val route2 = sender.expectMsgType[RouteResponse].routes.head - assert(route2.amount == 250_000.msat) - assert(route2NodeIds(route2) == Seq(a, b, c)) - assert(route2.channelFee(false) == 10.msat) - assert(route2.trampolineFee == 25_000.msat) - assert(route2.finalHop_opt.contains(trampolineHop)) - } - test("routes found (with blinded hops)") { fixture => import fixture._ val sender = TestProbe() diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Payment.scala b/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Payment.scala index e0528dcc4b..44f1669a17 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Payment.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Payment.scala @@ -17,14 +17,14 @@ package fr.acinq.eclair.api.handlers import akka.http.scaladsl.server.{MalformedFormFieldRejection, Route} -import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi} +import fr.acinq.bitcoin.scalacompat.Satoshi import fr.acinq.eclair.api.Service import fr.acinq.eclair.api.directives.EclairDirectives import fr.acinq.eclair.api.serde.FormParamExtractors._ import fr.acinq.eclair.payment.Bolt11Invoice import fr.acinq.eclair.payment.send.PaymentIdentifier import fr.acinq.eclair.router.Router.{PredefinedChannelRoute, PredefinedNodeRoute} -import fr.acinq.eclair.{CltvExpiryDelta, MilliSatoshi, randomBytes32} +import fr.acinq.eclair.{MilliSatoshi, randomBytes32} import java.util.UUID @@ -55,16 +55,13 @@ trait Payment { val sendToRoute: Route = postRequest("sendtoroute") { implicit t => withRoute { hops => - formFields(amountMsatFormParam, "recipientAmountMsat".as[MilliSatoshi].?, invoiceFormParam, "externalId".?, "parentId".as[UUID].?, - "trampolineSecret".as[ByteVector32].?, "trampolineFeesMsat".as[MilliSatoshi].?, "trampolineCltvExpiry".as[Int].?, maxFeeMsatFormParam.?) { - (amountMsat, recipientAmountMsat_opt, invoice, externalId_opt, parentId_opt, trampolineSecret_opt, trampolineFeesMsat_opt, trampolineCltvExpiry_opt, maxFee_opt) => { + formFields(amountMsatFormParam, "recipientAmountMsat".as[MilliSatoshi].?, invoiceFormParam, "externalId".?, "parentId".as[UUID].?, maxFeeMsatFormParam.?) { + (amountMsat, recipientAmountMsat_opt, invoice, externalId_opt, parentId_opt, maxFee_opt) => { val route = hops match { case Left(shortChannelIds) => PredefinedChannelRoute(amountMsat, invoice.nodeId, shortChannelIds, maxFee_opt) case Right(nodeIds) => PredefinedNodeRoute(amountMsat, nodeIds, maxFee_opt) } - complete(eclairApi.sendToRoute( - recipientAmountMsat_opt, externalId_opt, parentId_opt, invoice, route, trampolineSecret_opt, trampolineFeesMsat_opt, trampolineCltvExpiry_opt.map(CltvExpiryDelta)) - ) + complete(eclairApi.sendToRoute(recipientAmountMsat_opt, externalId_opt, parentId_opt, invoice, route)) } } } diff --git a/eclair-node/src/test/scala/fr/acinq/eclair/api/ApiServiceSpec.scala b/eclair-node/src/test/scala/fr/acinq/eclair/api/ApiServiceSpec.scala index f0b1326d42..636659f747 100644 --- a/eclair-node/src/test/scala/fr/acinq/eclair/api/ApiServiceSpec.scala +++ b/eclair-node/src/test/scala/fr/acinq/eclair/api/ApiServiceSpec.scala @@ -981,7 +981,7 @@ class ApiServiceSpec extends AnyFunSuite with ScalatestRouteTest with IdiomaticM } test("'sendtoroute' method should accept a json-encoded") { - val payment = SendPaymentToRouteResponse(UUID.fromString("487da196-a4dc-4b1e-92b4-3e5e905e9f3f"), UUID.fromString("2ad8c6d7-99cb-4238-8f67-89024b8eed0d"), None) + val payment = SendPaymentToRouteResponse(UUID.fromString("487da196-a4dc-4b1e-92b4-3e5e905e9f3f"), UUID.fromString("2ad8c6d7-99cb-4238-8f67-89024b8eed0d")) val expected = """{"paymentId":"487da196-a4dc-4b1e-92b4-3e5e905e9f3f","parentId":"2ad8c6d7-99cb-4238-8f67-89024b8eed0d"}""" val externalId = UUID.randomUUID().toString val pr = Bolt11Invoice(Block.LivenetGenesisBlock.hash, Some(1234 msat), ByteVector32.Zeroes, randomKey(), Left("Some invoice"), CltvExpiryDelta(24)) @@ -989,7 +989,7 @@ class ApiServiceSpec extends AnyFunSuite with ScalatestRouteTest with IdiomaticM val jsonNodes = serialization.write(expectedRoute.nodes) val eclair = mock[Eclair] - eclair.sendToRoute(any[Option[MilliSatoshi]], any[Option[String]], any[Option[UUID]], any[Bolt11Invoice], any[PredefinedNodeRoute], any[Option[ByteVector32]], any[Option[MilliSatoshi]], any[Option[CltvExpiryDelta]])(any[Timeout]) returns Future.successful(payment) + eclair.sendToRoute(any[Option[MilliSatoshi]], any[Option[String]], any[Option[UUID]], any[Bolt11Invoice], any[PredefinedNodeRoute])(any[Timeout]) returns Future.successful(payment) val mockService = new MockService(eclair) Post("/sendtoroute", FormData("nodeIds" -> jsonNodes, "amountMsat" -> "1234", "finalCltvExpiry" -> "190", "externalId" -> externalId, "invoice" -> pr.toString).toEntity) ~> @@ -1000,19 +1000,19 @@ class ApiServiceSpec extends AnyFunSuite with ScalatestRouteTest with IdiomaticM assert(handled) assert(status == OK) assert(entityAs[String] == expected) - eclair.sendToRoute(None, Some(externalId), None, pr, expectedRoute, None, None, None)(any[Timeout]).wasCalled(once) + eclair.sendToRoute(None, Some(externalId), None, pr, expectedRoute)(any[Timeout]).wasCalled(once) } } test("'sendtoroute' method should accept a comma separated list of pubkeys") { - val payment = SendPaymentToRouteResponse(UUID.fromString("487da196-a4dc-4b1e-92b4-3e5e905e9f3f"), UUID.fromString("2ad8c6d7-99cb-4238-8f67-89024b8eed0d"), None) + val payment = SendPaymentToRouteResponse(UUID.fromString("487da196-a4dc-4b1e-92b4-3e5e905e9f3f"), UUID.fromString("2ad8c6d7-99cb-4238-8f67-89024b8eed0d")) val expected = """{"paymentId":"487da196-a4dc-4b1e-92b4-3e5e905e9f3f","parentId":"2ad8c6d7-99cb-4238-8f67-89024b8eed0d"}""" val pr = Bolt11Invoice(Block.LivenetGenesisBlock.hash, Some(1234 msat), ByteVector32.Zeroes, randomKey(), Left("Some invoice"), CltvExpiryDelta(24)) val expectedRoute = PredefinedNodeRoute(1234 msat, Seq(PublicKey(hex"0217eb8243c95f5a3b7d4c5682d10de354b7007eb59b6807ae407823963c7547a9"), PublicKey(hex"0242a4ae0c5bef18048fbecf995094b74bfb0f7391418d71ed394784373f41e4f3"), PublicKey(hex"026ac9fcd64fb1aa1c491fc490634dc33da41d4a17b554e0adf1b32fee88ee9f28"))) val csvNodes = "0217eb8243c95f5a3b7d4c5682d10de354b7007eb59b6807ae407823963c7547a9, 0242a4ae0c5bef18048fbecf995094b74bfb0f7391418d71ed394784373f41e4f3, 026ac9fcd64fb1aa1c491fc490634dc33da41d4a17b554e0adf1b32fee88ee9f28" val eclair = mock[Eclair] - eclair.sendToRoute(any[Option[MilliSatoshi]], any[Option[String]], any[Option[UUID]], any[Bolt11Invoice], any[PredefinedNodeRoute], any[Option[ByteVector32]], any[Option[MilliSatoshi]], any[Option[CltvExpiryDelta]])(any[Timeout]) returns Future.successful(payment) + eclair.sendToRoute(any[Option[MilliSatoshi]], any[Option[String]], any[Option[UUID]], any[Bolt11Invoice], any[PredefinedNodeRoute])(any[Timeout]) returns Future.successful(payment) val mockService = new MockService(eclair) // this test uses CSV encoded route @@ -1024,7 +1024,7 @@ class ApiServiceSpec extends AnyFunSuite with ScalatestRouteTest with IdiomaticM assert(handled) assert(status == OK) assert(entityAs[String] == expected) - eclair.sendToRoute(None, None, None, pr, expectedRoute, None, None, None)(any[Timeout]).wasCalled(once) + eclair.sendToRoute(None, None, None, pr, expectedRoute)(any[Timeout]).wasCalled(once) } }