Skip to content

Commit

Permalink
Simplify trampoline test helpers (#2942)
Browse files Browse the repository at this point in the history
We previously supported sending arbitrary trampoline payments in eclair,
which added a bunch of complexity to many of our payment components.
This was necessary because the first version of Phoenix was based on the
eclair codebase. But Phoenix isn't based on eclair anymore and is now
using https://github.com/acinq/lightning-kmp, which is the library that
mobile wallets should use.

Eclair is only meant to be used for server nodes that relay payments and
have access to the full network graph, so it doesn't make sense ever to
send trampoline payments (full source-routing will always be better). We
thus refactor our trampoline client code to only be used for tests (to
ensure that trampoline relay and trampoline receive are implemented
correctly). We isolate the test payment lifecycle in a dedicated actor
(`TrampolinePaymentLifecycle`) which simplifies other payment components
(such as `PaymentInitiator`).

This refactoring will make it easier to support the official version of
trampoline while maintaining backwards-compatibility for older Phoenix
wallets that use the non-official trampoline version.
  • Loading branch information
t-bast authored Nov 21, 2024
1 parent a0b5834 commit 47fdfae
Show file tree
Hide file tree
Showing 19 changed files with 413 additions and 804 deletions.
70 changes: 0 additions & 70 deletions docs/TrampolinePayments.md

This file was deleted.

26 changes: 10 additions & 16 deletions eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down Expand Up @@ -184,7 +184,7 @@ trait Eclair {

def payOfferBlocking(offer: Offer, amount: MilliSatoshi, quantity: Long, externalId_opt: Option[String] = None, maxAttempts_opt: Option[Int] = None, maxFeeFlat_opt: Option[Satoshi] = None, maxFeePct_opt: Option[Double] = None, pathFindingExperimentName_opt: Option[String] = None, connectDirectly: Boolean = false)(implicit timeout: Timeout): Future[PaymentEvent]

def payOfferTrampoline(offer: Offer, amount: MilliSatoshi, quantity: Long, trampolineNodeId: PublicKey, trampolineAttempts: Seq[(MilliSatoshi, CltvExpiryDelta)], externalId_opt: Option[String] = None, maxAttempts_opt: Option[Int] = None, maxFeeFlat_opt: Option[Satoshi] = None, maxFeePct_opt: Option[Double] = None, pathFindingExperimentName_opt: Option[String] = None, connectDirectly: Boolean = false)(implicit timeout: Timeout): Future[UUID]
def payOfferTrampoline(offer: Offer, amount: MilliSatoshi, quantity: Long, trampolineNodeId: PublicKey, externalId_opt: Option[String] = None, maxAttempts_opt: Option[Int] = None, maxFeeFlat_opt: Option[Satoshi] = None, maxFeePct_opt: Option[Double] = None, pathFindingExperimentName_opt: Option[String] = None, connectDirectly: Boolean = false)(implicit timeout: Timeout): Future[PaymentEvent]

def getOnChainMasterPubKey(account: Long): String

Expand Down Expand Up @@ -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]
}
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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],
Expand All @@ -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))
Expand All @@ -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,
Expand All @@ -767,21 +762,20 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
maxFeePct_opt: Option[Double],
pathFindingExperimentName_opt: Option[String],
connectDirectly: Boolean)(implicit timeout: Timeout): Future[PaymentEvent] = {
payOfferInternal(offer, amount, quantity, None, Nil, externalId_opt, maxAttempts_opt, maxFeeFlat_opt, maxFeePct_opt, pathFindingExperimentName_opt, connectDirectly, blocking = true).mapTo[PaymentEvent]
payOfferInternal(offer, amount, quantity, None, externalId_opt, maxAttempts_opt, maxFeeFlat_opt, maxFeePct_opt, pathFindingExperimentName_opt, connectDirectly, blocking = true).mapTo[PaymentEvent]
}

override def payOfferTrampoline(offer: Offer,
amount: MilliSatoshi,
quantity: Long,
trampolineNodeId: PublicKey,
trampolineAttempts: Seq[(MilliSatoshi, CltvExpiryDelta)],
externalId_opt: Option[String],
maxAttempts_opt: Option[Int],
maxFeeFlat_opt: Option[Satoshi],
maxFeePct_opt: Option[Double],
pathFindingExperimentName_opt: Option[String],
connectDirectly: Boolean)(implicit timeout: Timeout): Future[UUID] = {
payOfferInternal(offer, amount, quantity, Some(trampolineNodeId), trampolineAttempts, externalId_opt, maxAttempts_opt, maxFeeFlat_opt, maxFeePct_opt, pathFindingExperimentName_opt, connectDirectly, blocking = false).mapTo[UUID]
connectDirectly: Boolean)(implicit timeout: Timeout): Future[PaymentEvent] = {
payOfferInternal(offer, amount, quantity, Some(trampolineNodeId), externalId_opt, maxAttempts_opt, maxFeeFlat_opt, maxFeePct_opt, pathFindingExperimentName_opt, connectDirectly, blocking = true).mapTo[PaymentEvent]
}

override def getDescriptors(account: Long): Descriptors = appKit.nodeParams.onChainKeyManager_opt match {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -123,9 +121,9 @@ private class OfferPayment(replyTo: ActorRef,
private def waitForInvoice(attemptNumber: Int, pathNodeId: PublicKey): Behavior[Command] = {
Behaviors.receiveMessagePartial {
case WrappedMessageResponse(Postman.Response(payload: InvoicePayload)) if payload.invoice.validateFor(invoiceRequest, pathNodeId).isRight =>
sendPaymentConfig.trampoline match {
case Some(trampoline) =>
paymentInitiator ! SendTrampolinePayment(replyTo, payload.invoice.amount, payload.invoice, trampoline.nodeId, trampoline.attempts, sendPaymentConfig.routeParams)
sendPaymentConfig.trampolineNodeId_opt match {
case Some(trampolineNodeId) =>
paymentInitiator ! SendTrampolinePayment(replyTo, payload.invoice, trampolineNodeId, sendPaymentConfig.routeParams, sendPaymentConfig.blocking)
Behaviors.stopped
case None =>
context.spawnAnonymous(BlindedPathsResolver(nodeParams, payload.invoice.paymentHash, router, register)) ! Resolve(context.messageAdapter[Seq[ResolvedPath]](WrappedResolvedPaths), payload.invoice.blindedPaths)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
Loading

0 comments on commit 47fdfae

Please sign in to comment.