1
0
mirror of https://github.com/ACINQ/eclair.git synced 2024-11-19 01:43:22 +01:00
This commit is contained in:
t-bast 2024-11-01 15:14:57 +01:00
parent 96d0c9a35b
commit e852569afc
No known key found for this signature in database
GPG Key ID: 34F377B0100ED6BB
18 changed files with 365 additions and 760 deletions

View File

@ -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).

View File

@ -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[UUID]
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]
payOfferInternal(offer, amount, quantity, Some(trampolineNodeId), externalId_opt, maxAttempts_opt, maxFeeFlat_opt, maxFeePct_opt, pathFindingExperimentName_opt, connectDirectly, blocking = false).mapTo[UUID]
}
override def getDescriptors(account: Long): Descriptors = appKit.nodeParams.onChainKeyManager_opt match {

View File

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

View File

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

View File

@ -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)
Behaviors.stopped
case None =>
context.spawnAnonymous(BlindedPathsResolver(nodeParams, payload.invoice.paymentHash, router, register)) ! Resolve(context.messageAdapter[Seq[ResolvedPath]](WrappedResolvedPaths), payload.invoice.blindedPaths)

View File

@ -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" }

View File

@ -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
@ -81,88 +80,42 @@ 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.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 +133,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 +150,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 +166,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 +182,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 +210,20 @@ 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 invoice Bolt 11 invoice.
* @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.
*/
case class SendTrampolinePayment(replyTo: ActorRef,
recipientAmount: MilliSatoshi,
invoice: Invoice,
trampolineNodeId: PublicKey,
trampolineAttempts: Seq[(MilliSatoshi, CltvExpiryDelta)],
routeParams: RouteParams) extends SendRequestedPayment
routeParams: RouteParams) 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 +268,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.

View File

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

View File

@ -0,0 +1,221 @@
/*
* 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.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, NodeParams, randomBytes32}
import java.util.UUID
/**
* 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
val parts = Seq((amount1, channel1), (amount2, channel2)).map { case (amount, channelInfo) =>
val outgoing = buildOutgoingPayment(cmd.trampolineNodeId, cmd.invoice, amount, expiry, 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) {
cmd.replyTo ! PaymentFailed(cmd.paymentId, paymentHash, LocalFailure(totalAmount, Nil, failure.t) :: Nil)
Behaviors.stopped
} else {
waitForSettlement(remaining - 1, attemptNumber, parts)
}
}
case WrappedHtlcSettled(result) => result.result match {
case fulfill: HtlcResult.Fulfill =>
context.log.info("trampoline payment succeeded")
// NB: we don't bother waiting for the settlement of the other HTLC.
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) {
retryOrStop(attemptNumber + 1)
} else {
waitForSettlement(remaining - 1, attemptNumber, parts)
}
}
}
}
private def retryOrStop(attemptNumber: Int): Behavior[Command] = {
val nextFees = computeFees(totalAmount, attemptNumber)
if (cmd.routeParams.getMaxFee(totalAmount) < nextFees) {
cmd.replyTo ! PaymentFailed(cmd.paymentId, paymentHash, LocalFailure(totalAmount, Nil, new RuntimeException("maximum trampoline fees exceeded")) :: Nil)
Behaviors.stopped
} else {
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, attemptNumber = 0)
}
def buildOutgoingPayment(trampolineNodeId: PublicKey, invoice: Invoice, amount: MilliSatoshi, expiry: CltvExpiry, 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(amount, expiry, invoice.nodeId)
buildOnion(NodePayload(trampolineNodeId, trampolinePayload) :: NodePayload(invoice.nodeId, finalPayload) :: Nil, invoice.paymentHash, None).toOption.get
case invoice: Bolt11Invoice =>
val dummyPayload = PaymentOnion.FinalPayload.Standard.createPayload(amount, totalAmount, expiry, invoice.paymentSecret, invoice.paymentMetadata)
val trampolinePayload = PaymentOnion.IntermediatePayload.NodeRelay.Standard.createNodeRelayToNonTrampolinePayload(amount, 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(amount, expiry, invoice)
buildOnion(NodePayload(trampolineNodeId, trampolinePayload) :: Nil, invoice.paymentHash, None).toOption.get
}
val trampolineAmount = computeTrampolineAmount(amount, attemptNumber)
val trampolineExpiry = computeTrampolineExpiry(expiry, attemptNumber)
// We generate a random secret to avoid leaking the invoice secret to the trampoline node.
val trampolinePaymentSecret = randomBytes32()
val payload = PaymentOnion.FinalPayload.Standard.createTrampolinePayload(trampolineAmount, trampolineAmount, 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 computeTrampolineAmount(amount: MilliSatoshi, attemptNumber: Int): MilliSatoshi = amount * (1 + (attemptNumber + 1) * 0.002)
def computeFees(amount: MilliSatoshi, attemptNumber: Int): MilliSatoshi = amount * (attemptNumber + 1) * 0.002
// We increase the trampoline expiry delta at each attempt.
private def computeTrampolineExpiry(expiry: CltvExpiry, attemptNumber: Int): CltvExpiry = expiry + CltvExpiryDelta(144) * (attemptNumber + 1)
}

View File

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

View File

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

View File

@ -462,7 +462,7 @@ 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
@ -471,11 +471,8 @@ class PaymentIntegrationSpec extends IntegrationSpec {
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,14 +492,7 @@ 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 < 7_500_000.msat, relayed)
}
test("send a trampoline payment D->B (via trampoline C)") {
@ -518,20 +506,14 @@ class PaymentIntegrationSpec extends IntegrationSpec {
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 < 10_000_000.msat, relayed)
}
test("send a trampoline payment F1->A (via trampoline C, non-trampoline recipient)") {
@ -575,15 +545,13 @@ class PaymentIntegrationSpec extends IntegrationSpec {
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)
@ -623,7 +591,7 @@ class PaymentIntegrationSpec extends IntegrationSpec {
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)
@ -644,7 +612,7 @@ class PaymentIntegrationSpec extends IntegrationSpec {
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)
@ -657,37 +625,6 @@ class PaymentIntegrationSpec extends IntegrationSpec {
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") {
val recipientKey = randomKey()
val amount = 50_000_000 msat
@ -838,7 +775,7 @@ 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)))

View File

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

View File

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

View File

@ -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 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(_, 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") {

View File

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

View File

@ -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()

View File

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