mirror of
https://github.com/ACINQ/eclair.git
synced 2024-11-19 01:43:22 +01:00
WIP
This commit is contained in:
parent
96d0c9a35b
commit
e852569afc
@ -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).
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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" }
|
||||
|
@ -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.
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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 =>
|
||||
|
@ -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)))
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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") {
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user