Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions docs/TrampolinePayments.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Eclair started supporting [trampoline payments](https://github.com/lightning/bol

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 s trampoline node. Update your `eclair.conf` with the following values:
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
Expand All @@ -24,12 +24,12 @@ 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 her, splitting the payment in two parts.
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 --trampolineNodes=$BOB_ID,$DAVE_ID --trampolineFeesMsat=100000 --trampolineCltvExpiry=450 --finalCltvExpiry=16 --invoice=lntb1500n1pwxx94fp...
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.
Expand All @@ -51,12 +51,15 @@ The `trampolineSecret` is also important: this is what prevents a malicious tram
Now that you have those, you can send the second part:

```sh
eclair-cli sendtoroute --amountMsat=250000 --parentId=cd083b31-5939-46ac-bf90-8ac5b286a9e2 --trampolineSecret=9e13d1b602496871bb647b48e8ff8f15a91c07affb0a3599e995d470ac488715 --nodeIds=$ALICE_ID,$BOB_ID --trampolineNodes=$BOB_ID,$DAVE_ID --trampolineFeesMsat=100000 --trampolineCltvExpiry=450 --finalCltvExpiry=16 --invoice=lntb1500n1pwxx94fp...
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. This is something we will improve if our users ask for a better API.
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:

Expand All @@ -65,4 +68,3 @@ 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).

1 change: 1 addition & 0 deletions docs/release-notes/eclair-vnext.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
### API changes

- `audit` now accepts `--count` and `--skip` parameters to limit the number of retrieved items (#2474, #2487)
- `sendtoroute` removes the `--trampolineNodes` argument and implicitly uses a single trampoline hop (#2480)

### Miscellaneous improvements and bug fixes

Expand Down
25 changes: 16 additions & 9 deletions eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import fr.acinq.eclair.message.{OnionMessages, Postman}
import fr.acinq.eclair.payment._
import fr.acinq.eclair.payment.receive.MultiPartHandler.ReceiveStandardPayment
import fr.acinq.eclair.payment.relay.Relayer.{ChannelBalance, GetOutgoingChannels, OutgoingChannels, RelayFees}
import fr.acinq.eclair.payment.send.ClearRecipient
import fr.acinq.eclair.payment.send.MultiPartPaymentLifecycle.PreimageReceived
import fr.acinq.eclair.payment.send.PaymentInitiator._
import fr.acinq.eclair.router.Router
Expand Down Expand Up @@ -125,7 +126,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(amount: MilliSatoshi, 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, trampolineNodes_opt: Seq[PublicKey] = Nil)(implicit timeout: Timeout): Future[SendPaymentToRouteResponse]
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 audit(from: TimestampSecond, to: TimestampSecond, paginated_opt: Option[Paginated])(implicit timeout: Timeout): Future[AuditResponse]

Expand Down Expand Up @@ -312,30 +313,36 @@ class EclairImpl(appKit: Kit) extends Eclair with Logging {
override 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] = {
getRouteParams(pathFindingExperimentName_opt) match {
case Right(routeParams) =>
val maxFee = maxFee_opt.getOrElse(routeParams.getMaxFee(amount))
val target = ClearRecipient(targetNodeId, Features.empty, amount, CltvExpiry(appKit.nodeParams.currentBlockHeight), ByteVector32.Zeroes, extraEdges)
val routeParams1 = routeParams.copy(
includeLocalChannelCost = includeLocalChannelCost,
boundaries = routeParams.boundaries.copy(
maxFeeFlat = maxFee_opt.getOrElse(routeParams.boundaries.maxFeeFlat),
maxFeeProportional = maxFee_opt.map(_ => 0.0).getOrElse(routeParams.boundaries.maxFeeProportional)
)
)
for {
ignoredChannels <- getChannelDescs(ignoreShortChannelIds.toSet)
ignore = Ignore(ignoreNodeIds.toSet, ignoredChannels)
response <- (appKit.router ? RouteRequest(sourceNodeId, targetNodeId, amount, maxFee, extraEdges, ignore = ignore, routeParams = routeParams.copy(includeLocalChannelCost = includeLocalChannelCost))).mapTo[RouteResponse]
response <- (appKit.router ? RouteRequest(sourceNodeId, target, routeParams1, ignore)).mapTo[RouteResponse]
} yield response
case Left(t) => Future.failed(t)
}
}

override def sendToRoute(amount: MilliSatoshi, 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], trampolineNodes_opt: Seq[PublicKey])(implicit timeout: Timeout): Future[SendPaymentToRouteResponse] = {
val recipientAmount = recipientAmount_opt.getOrElse(invoice.amount_opt.getOrElse(amount))
val sendPayment = SendPaymentToRoute(amount, recipientAmount, invoice, route, externalId_opt, parentId_opt, trampolineSecret_opt, trampolineFees_opt.getOrElse(0 msat), trampolineExpiryDelta_opt.getOrElse(CltvExpiryDelta(0)), trampolineNodes_opt)
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] = {
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 (trampolineNodes_opt.nonEmpty && (trampolineFees_opt.isEmpty || trampolineExpiryDelta_opt.isEmpty)) {
} else if (trampolineFees_opt.nonEmpty && trampolineExpiryDelta_opt.isEmpty) {
Future.failed(new IllegalArgumentException("trampoline payments must specify a trampoline fee and cltv delta"))
} else if (trampolineNodes_opt.nonEmpty && trampolineNodes_opt.length != 2) {
Future.failed(new IllegalArgumentException("trampoline payments currently only support paying a trampoline node via a single other trampoline node"))
} 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, route, externalId_opt, parentId_opt, trampoline_opt)
(appKit.paymentInitiator ? sendPayment).mapTo[SendPaymentToRouteResponse]
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import fr.acinq.eclair.io.Peer
import fr.acinq.eclair.message.OnionMessages
import fr.acinq.eclair.payment.PaymentFailure.PaymentFailedSummary
import fr.acinq.eclair.payment._
import fr.acinq.eclair.router.Router.{ChannelRelayParams, Route}
import fr.acinq.eclair.router.Router.{HopRelayParams, NodeHop, Route}
import fr.acinq.eclair.transactions.DirectedHtlc
import fr.acinq.eclair.transactions.Transactions._
import fr.acinq.eclair.wire.protocol.MessageOnionCodecs.blindedRouteCodec
Expand Down Expand Up @@ -294,30 +294,48 @@ object ColorSerializer extends MinimalSerializer({
})

// @formatter:off
private case class ChannelHopJson(nodeId: PublicKey, nextNodeId: PublicKey, source: ChannelRelayParams)
private case class RouteFullJson(amount: MilliSatoshi, hops: Seq[ChannelHopJson])
object RouteFullSerializer extends ConvertClassSerializer[Route](route => RouteFullJson(route.amount, route.hops.map(h => ChannelHopJson(h.nodeId, h.nextNodeId, h.params))))
private sealed trait HopJson
private case class ChannelHopJson(nodeId: PublicKey, nextNodeId: PublicKey, source: HopRelayParams) extends HopJson
private case class NodeHopJson(nodeId: PublicKey, nextNodeId: PublicKey, fee: MilliSatoshi, cltvExpiryDelta: CltvExpiryDelta) extends HopJson
private case class RouteFullJson(amount: MilliSatoshi, hops: Seq[HopJson])
object RouteFullSerializer extends ConvertClassSerializer[Route](route => {
val channelHops = route.hops.map(h => ChannelHopJson(h.nodeId, h.nextNodeId, h.params))
val finalHop_opt = route.finalHop_opt.map {
case h: NodeHop => NodeHopJson(h.nodeId, h.nextNodeId, h.fee, h.cltvExpiryDelta)
}
RouteFullJson(route.amount, channelHops ++ finalHop_opt.toSeq)
})

private case class RouteNodeIdsJson(amount: MilliSatoshi, nodeIds: Seq[PublicKey])
object RouteNodeIdsSerializer extends ConvertClassSerializer[Route](route => {
val nodeIds = route.hops match {
case rest :+ last => rest.map(_.nodeId) :+ last.nodeId :+ last.nextNodeId
case Nil => Nil
val channelNodeIds = route.hops.headOption match {
case Some(hop) => Seq(hop.nodeId, hop.nextNodeId) ++ route.hops.tail.map(_.nextNodeId)
case None => Nil
}
val finalNodeIds = route.finalHop_opt match {
case Some(hop: NodeHop) if channelNodeIds.nonEmpty => Seq(hop.nextNodeId)
case Some(hop: NodeHop) => Seq(hop.nodeId, hop.nextNodeId)
case None => Nil
}
RouteNodeIdsJson(route.amount, nodeIds)
RouteNodeIdsJson(route.amount, channelNodeIds ++ finalNodeIds)
})

private case class RouteShortChannelIdsJson(amount: MilliSatoshi, shortChannelIds: Seq[ShortChannelId])
object RouteShortChannelIdsSerializer extends ConvertClassSerializer[Route](route => RouteShortChannelIdsJson(route.amount, route.hops.map(_.shortChannelId)))
private case class RouteShortChannelIdsJson(amount: MilliSatoshi, shortChannelIds: Seq[ShortChannelId], finalHop: Option[String])
object RouteShortChannelIdsSerializer extends ConvertClassSerializer[Route](route => {
val hops = route.hops.map(_.shortChannelId)
val finalHop = route.finalHop_opt.map {
case _: NodeHop => "trampoline"
}
RouteShortChannelIdsJson(route.amount, hops, finalHop)
})
// @formatter:on

// @formatter:off
private case class PaymentFailureSummaryJson(amount: MilliSatoshi, route: Seq[PublicKey], message: String)
private case class PaymentFailedSummaryJson(paymentHash: ByteVector32, destination: PublicKey, totalAmount: MilliSatoshi, pathFindingExperiment: String, failures: Seq[PaymentFailureSummaryJson])
private case class PaymentFailedSummaryJson(paymentHash: ByteVector32, destination: PublicKey, pathFindingExperiment: String, failures: Seq[PaymentFailureSummaryJson])
object PaymentFailedSummarySerializer extends ConvertClassSerializer[PaymentFailedSummary](p => PaymentFailedSummaryJson(
p.cfg.paymentHash,
p.cfg.recipientNodeId,
p.cfg.recipientAmount,
p.pathFindingExperiment,
p.paymentFailed.failures.map(f => {
val route = f.route.map(_.nodeId) ++ f.route.lastOption.map(_.nextNodeId)
Expand Down Expand Up @@ -512,8 +530,8 @@ object CustomTypeHints {
))

val channelSources: CustomTypeHints = CustomTypeHints(Map(
classOf[ChannelRelayParams.FromAnnouncement] -> "announcement",
classOf[ChannelRelayParams.FromHint] -> "hint"
classOf[HopRelayParams.FromAnnouncement] -> "announcement",
classOf[HopRelayParams.FromHint] -> "hint"
))

val channelStates: ShortTypeHints = ShortTypeHints(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -549,53 +549,12 @@ object Bolt11Invoice {
signature = bolt11Data.signature)
}

private def readBoltData(input: String): Bolt11Data = {
val lowercaseInput = input.toLowerCase
val separatorIndex = lowercaseInput.lastIndexOf('1')
val hrp = lowercaseInput.take(separatorIndex)
if (!prefixes.values.exists(prefix => hrp.startsWith(prefix))) throw new RuntimeException("unknown prefix")
val data = string2Bits(lowercaseInput.slice(separatorIndex + 1, lowercaseInput.length - 6)) // 6 == checksum size
Codecs.bolt11DataCodec.decode(data).require.value
}

/**
* Extracts the description from a serialized invoice that is **expected to be valid**.
* Throws an error if the invoice is not valid.
*
* @param input valid serialized invoice
* @return description as a String. If the description is a hash, returns the hash value as a String.
*/
def fastReadDescription(input: String): String = {
readBoltData(input).taggedFields.collectFirst {
case Bolt11Invoice.Description(d) => d
case Bolt11Invoice.DescriptionHash(h) => h.toString()
}.get
}

/**
* Checks if a serialized invoice is expired. Timestamp is compared to the System's current time.
*
* @param input valid serialized invoice
* @return true if the invoice has expired, false otherwise.
*/
def fastHasExpired(input: String): Boolean = {
val bolt11Data = readBoltData(input)
val expiry_opt = bolt11Data.taggedFields.collectFirst {
case p: Bolt11Invoice.Expiry => p
}
val timestamp = bolt11Data.timestamp
expiry_opt match {
case Some(expiry) => timestamp + expiry.toLong <= TimestampSecond.now()
case None => timestamp + DEFAULT_EXPIRY_SECONDS <= TimestampSecond.now()
}
}

def toExtraEdges(extraRoute: Seq[ExtraHop], targetNodeId: PublicKey): Seq[Invoice.ExtraEdge] = {
// BOLT 11: "For each entry, the pubkey is the node ID of the start of the channel", and the last node is the destination
val nextNodeIds = extraRoute.map(_.nodeId).drop(1) :+ targetNodeId
extraRoute.zip(nextNodeIds).map {
case (extraHop, nextNodeId) =>
Invoice.BasicEdge(extraHop.nodeId, nextNodeId, extraHop.shortChannelId, extraHop.feeBase, extraHop.feeProportionalMillionths, extraHop.cltvExpiryDelta)
Invoice.ExtraEdge(extraHop.nodeId, nextNodeId, extraHop.shortChannelId, extraHop.feeBase, extraHop.feeProportionalMillionths, extraHop.cltvExpiryDelta, 1 msat, None)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import fr.acinq.eclair.crypto.Sphinx.RouteBlinding
import fr.acinq.eclair.wire.protocol.OfferTypes._
import fr.acinq.eclair.wire.protocol.OnionRoutingCodecs.{InvalidTlvPayload, MissingRequiredTlv}
import fr.acinq.eclair.wire.protocol.{OfferCodecs, OfferTypes, TlvStream}
import fr.acinq.eclair.{CltvExpiryDelta, Features, InvoiceFeature, MilliSatoshi, MilliSatoshiLong, TimestampSecond, UInt64, randomBytes32}
import fr.acinq.eclair.{CltvExpiryDelta, Features, InvoiceFeature, MilliSatoshi, TimestampSecond, UInt64}
import scodec.bits.ByteVector

import java.util.concurrent.TimeUnit
Expand All @@ -43,10 +43,7 @@ case class Bolt12Invoice(records: TlvStream[InvoiceTlv]) extends Invoice {
override val amount_opt: Option[MilliSatoshi] = Some(amount)
override val nodeId: Crypto.PublicKey = records.get[NodeId].get.publicKey
override val paymentHash: ByteVector32 = records.get[PaymentHash].get.hash
override val paymentSecret: ByteVector32 = randomBytes32()
override val paymentMetadata: Option[ByteVector] = None
override val description: Either[String, ByteVector32] = Left(records.get[Description].get.description)
override val extraEdges: Seq[Invoice.ExtraEdge] = Seq.empty // TODO: the blinded paths need to be converted to graph edges
override val createdAt: TimestampSecond = records.get[CreatedAt].get.timestamp
override val relativeExpiry: FiniteDuration = FiniteDuration(records.get[RelativeExpiry].map(_.seconds).getOrElse(DEFAULT_EXPIRY_SECONDS), TimeUnit.SECONDS)
override val minFinalCltvExpiryDelta: CltvExpiryDelta = records.get[Cltv].map(_.minFinalCltvExpiry).getOrElse(DEFAULT_MIN_FINAL_EXPIRY_DELTA)
Expand Down
Loading