Skip to content

Commit

Permalink
Add support for official trampoline payments
Browse files Browse the repository at this point in the history
We add support for the official version of trampoline payments, as
specified in lightning/bolts#836.

We keep supporting trampoline payments that use the legacy protocol
to allow a smooth transition. We hardcode the legacy feature bit 149
in a few places to make this work, which is a bit hacky but simple
and should be removed 6 months after releasing the official version.

We also keep supporting payments from trampoline wallets to nodes that
don't support trampoline: this is bad from a privacy standpoint, but
will be fixed when recipients start supporting Bolt 12.
  • Loading branch information
t-bast committed Nov 27, 2024
1 parent 71465d8 commit 4f57a4f
Show file tree
Hide file tree
Showing 19 changed files with 311 additions and 132 deletions.
14 changes: 14 additions & 0 deletions docs/release-notes/eclair-vnext.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,20 @@ Every node advertizes the rates at which they sell their liquidity, and buyers c
The liquidity ads specification is still under review and will likely change.
This feature isn't meant to be used on mainnet yet and is thus disabled by default.

### Trampoline payments

Trampoline payments allow nodes running on constrained devices to sync only a small portion of the network and leverage trampoline nodes to calculate the missing parts of the payment route, while providing the same privacy as fully source-routed payments.

Eclair started supporting [trampoline payments](https://github.com/lightning/bolts/pull/829) in v0.3.3.
The specification has evolved since then and has recently been added to the [BOLTs](https://github.com/lightning/bolts/pull/836).

With this release, eclair nodes are able to relay and receive trampoline payments (activated by default).
This feature can be disabled if you don't want to relay or receive trampoline payments:

```conf
eclair.features.trampoline_routing = disabled
```

### Update minimal version of Bitcoin Core

With this release, eclair requires using Bitcoin Core 27.2.
Expand Down
3 changes: 1 addition & 2 deletions eclair-core/src/main/resources/reference.conf
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ eclair {
node-alias = "eclair"
node-color = "49daaa"

trampoline-payments-enable = false // TODO: @t-bast: once spec-ed this should use a global feature flag
// see https://github.com/lightningnetwork/lightning-rfc/blob/master/09-features.md
features {
// option_upfront_shutdown_script is not activated by default.
Expand Down Expand Up @@ -81,7 +80,7 @@ eclair {
// node that you trust using override-init-features (see below).
option_zeroconf = disabled
keysend = disabled
trampoline_payment_prototype = disabled
trampoline_payment = optional
async_payment_prototype = disabled
on_the_fly_funding = disabled
}
Expand Down
18 changes: 6 additions & 12 deletions eclair-core/src/main/scala/fr/acinq/eclair/Features.scala
Original file line number Diff line number Diff line change
Expand Up @@ -300,15 +300,9 @@ object Features {
val mandatory = 54
}

// TODO: @t-bast: update feature bits once spec-ed (currently reserved here: https://github.com/lightningnetwork/lightning-rfc/issues/605)
// We're not advertising these bits yet in our announcements, clients have to assume support.
// This is why we haven't added them yet to `areSupported`.
// The version of trampoline enabled by this feature bit does not match the latest spec PR: once the spec is accepted,
// we will introduce a new version of trampoline that will work in parallel to this legacy one, until we can safely
// deprecate it.
case object TrampolinePaymentPrototype extends Feature with InitFeature with NodeFeature with Bolt11Feature {
val rfcName = "trampoline_payment_prototype"
val mandatory = 148
case object TrampolinePayment extends Feature with InitFeature with NodeFeature with Bolt11Feature with Bolt12Feature {
val rfcName = "trampoline_routing"
val mandatory = 56
}

// TODO: @remyers update feature bits once spec-ed (currently reserved here: https://github.com/lightning/bolts/pull/989)
Expand Down Expand Up @@ -363,7 +357,7 @@ object Features {
PaymentMetadata,
ZeroConf,
KeySend,
TrampolinePaymentPrototype,
TrampolinePayment,
AsyncPaymentPrototype,
SplicePrototype,
OnTheFlyFunding,
Expand All @@ -378,9 +372,9 @@ object Features {
AnchorOutputs -> (StaticRemoteKey :: Nil),
AnchorOutputsZeroFeeHtlcTx -> (StaticRemoteKey :: Nil),
RouteBlinding -> (VariableLengthOnion :: Nil),
TrampolinePaymentPrototype -> (PaymentSecret :: Nil),
TrampolinePayment -> (BasicMultiPartPayment :: Nil),
KeySend -> (VariableLengthOnion :: Nil),
AsyncPaymentPrototype -> (TrampolinePaymentPrototype :: Nil),
AsyncPaymentPrototype -> (TrampolinePayment :: Nil),
OnTheFlyFunding -> (SplicePrototype :: Nil),
FundingFeeCredit -> (OnTheFlyFunding :: Nil)
)
Expand Down
2 changes: 0 additions & 2 deletions eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,6 @@ case class NodeParams(nodeKeyManager: NodeKeyManager,
socksProxy_opt: Option[Socks5ProxyParams],
maxPaymentAttempts: Int,
paymentFinalExpiry: PaymentFinalExpiryConf,
enableTrampolinePayment: Boolean,
balanceCheckInterval: FiniteDuration,
blockchainWatchdogThreshold: Int,
blockchainWatchdogSources: Seq[String],
Expand Down Expand Up @@ -657,7 +656,6 @@ object NodeParams extends Logging {
socksProxy_opt = socksProxy_opt,
maxPaymentAttempts = config.getInt("max-payment-attempts"),
paymentFinalExpiry = PaymentFinalExpiryConf(CltvExpiryDelta(config.getInt("send.recipient-final-expiry.min-delta")), CltvExpiryDelta(config.getInt("send.recipient-final-expiry.max-delta"))),
enableTrampolinePayment = config.getBoolean("trampoline-payments-enable"),
balanceCheckInterval = FiniteDuration(config.getDuration("balance-check-interval").getSeconds, TimeUnit.SECONDS),
blockchainWatchdogThreshold = config.getInt("blockchain-watchdog.missing-blocks-threshold"),
blockchainWatchdogSources = config.getStringList("blockchain-watchdog.sources").asScala.toSeq,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,9 @@ object IncomingPaymentPacket {
case None if add.blinding_opt.isDefined => Left(InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket)))
case None =>
// We check if the payment is using trampoline: if it is, we may not be the final recipient.
payload.get[OnionPaymentPayloadTlv.TrampolineOnion] match {
case Some(OnionPaymentPayloadTlv.TrampolineOnion(trampolinePacket)) =>
val trampolinePacket_opt = payload.get[OnionPaymentPayloadTlv.TrampolineOnion].map(_.packet).orElse(payload.get[OnionPaymentPayloadTlv.LegacyTrampolineOnion].map(_.packet))
trampolinePacket_opt match {
case Some(trampolinePacket) =>
// NB: when we enable blinded trampoline routes, we will need to check if the outer onion contains a
// blinding point and use it to derive the decryption key for the blinded trampoline onion.
decryptOnion(add.paymentHash, privateKey, trampolinePacket).flatMap {
Expand Down Expand Up @@ -303,7 +304,10 @@ object OutgoingPaymentPacket {
* In that case, packetPayloadLength_opt must be greater than the actual onion's content.
*/
def buildOnion(payloads: Seq[NodePayload], associatedData: ByteVector32, packetPayloadLength_opt: Option[Int]): Either[OutgoingPaymentError, Sphinx.PacketAndSecrets] = {
val sessionKey = randomKey()
buildOnion(randomKey(), payloads, associatedData, packetPayloadLength_opt)
}

def buildOnion(sessionKey: PrivateKey, payloads: Seq[NodePayload], associatedData: ByteVector32, packetPayloadLength_opt: Option[Int]): Either[OutgoingPaymentError, Sphinx.PacketAndSecrets] = {
val nodeIds = payloads.map(_.nodeId)
val payloadsBin = payloads
.map(p => PaymentOnionCodecs.perHopPayloadCodec.encode(p.payload.records))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -329,11 +329,6 @@ object MultiPartHandler {
val paymentHash = Crypto.sha256(paymentPreimage)
val expirySeconds = r.expirySeconds_opt.getOrElse(nodeParams.invoiceExpiry.toSeconds)
val paymentMetadata = hex"2a"
val featuresTrampolineOpt = if (nodeParams.enableTrampolinePayment) {
nodeParams.features.bolt11Features().add(Features.TrampolinePaymentPrototype, FeatureSupport.Optional)
} else {
nodeParams.features.bolt11Features()
}
val invoice = Bolt11Invoice(
nodeParams.chainHash,
r.amount_opt,
Expand All @@ -345,7 +340,7 @@ object MultiPartHandler {
expirySeconds = Some(expirySeconds),
extraHops = r.extraHops,
paymentMetadata = Some(paymentMetadata),
features = featuresTrampolineOpt
features = nodeParams.features.bolt11Features()
)
context.log.debug("generated invoice={} from amount={}", invoice.toString, r.amount_opt)
nodeParams.db.payments.addIncomingPayment(invoice, paymentPreimage, r.paymentType)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ import fr.acinq.eclair.router.Router.{ChannelHop, HopRelayParams, Route, RoutePa
import fr.acinq.eclair.router.{BalanceTooLow, RouteNotFound}
import fr.acinq.eclair.wire.protocol.PaymentOnion.IntermediatePayload
import fr.acinq.eclair.wire.protocol._
import fr.acinq.eclair.{Alias, CltvExpiry, CltvExpiryDelta, EncodedNodeId, Features, InitFeature, Logs, MilliSatoshi, MilliSatoshiLong, NodeParams, TimestampMilli, UInt64, nodeFee, randomBytes32}
import fr.acinq.eclair.{Alias, CltvExpiry, CltvExpiryDelta, EncodedNodeId, FeatureSupport, Features, InitFeature, InvoiceFeature, Logs, MilliSatoshi, MilliSatoshiLong, NodeParams, TimestampMilli, UInt64, UnknownFeature, nodeFee, randomBytes32}

import java.util.UUID
import java.util.concurrent.TimeUnit
Expand Down Expand Up @@ -255,7 +255,9 @@ class NodeRelay private(nodeParams: NodeParams,
nextPayload match {
case payloadOut: IntermediatePayload.NodeRelay.Standard =>
val paymentSecret = randomBytes32() // we generate a new secret to protect against probing attacks
val recipient = ClearRecipient(payloadOut.outgoingNodeId, Features.empty, payloadOut.amountToForward, payloadOut.outgoingCltv, paymentSecret, nextTrampolineOnion_opt = nextPacket_opt)
// If the recipient is using the legacy trampoline feature, we will use the legacy onion format.
val features = if (payloadOut.isLegacy) Features(Map.empty[InvoiceFeature, FeatureSupport], Set(UnknownFeature(149))) else Features.empty[InvoiceFeature]
val recipient = ClearRecipient(payloadOut.outgoingNodeId, features, payloadOut.amountToForward, payloadOut.outgoingCltv, paymentSecret, nextTrampolineOnion_opt = nextPacket_opt)
context.log.debug("forwarding payment to the next trampoline node {}", recipient.nodeId)
ensureRecipientReady(upstream, recipient, nextPayload, nextPacket_opt)
case payloadOut: IntermediatePayload.NodeRelay.ToNonTrampoline =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import fr.acinq.eclair.channel._
import fr.acinq.eclair.db.PendingCommandsDb
import fr.acinq.eclair.payment._
import fr.acinq.eclair.wire.protocol._
import fr.acinq.eclair.{CltvExpiryDelta, Logs, MilliSatoshi, NodeParams}
import fr.acinq.eclair.{CltvExpiryDelta, Features, Logs, MilliSatoshi, NodeParams}
import grizzled.slf4j.Logging

import scala.concurrent.Promise
Expand Down Expand Up @@ -71,7 +71,7 @@ class Relayer(nodeParams: NodeParams, router: ActorRef, register: ActorRef, paym
case Right(r: IncomingPaymentPacket.ChannelRelayPacket) =>
channelRelayer ! ChannelRelayer.Relay(r, originNode)
case Right(r: IncomingPaymentPacket.NodeRelayPacket) =>
if (!nodeParams.enableTrampolinePayment) {
if (!nodeParams.features.hasFeature(Features.TrampolinePayment)) {
log.warning(s"rejecting htlc #${add.id} from channelId=${add.channelId} reason=trampoline disabled")
PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, add.channelId, CMD_FAIL_HTLC(add.id, Right(RequiredNodeFeatureMissing()), commit = true))
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ 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, ShortChannelId}
import fr.acinq.eclair.{CltvExpiry, Features, InvoiceFeature, MilliSatoshi, ShortChannelId, UnknownFeature}
import scodec.bits.ByteVector

/**
Expand Down Expand Up @@ -74,9 +74,13 @@ case class ClearRecipient(nodeId: PublicKey,
paymentMetadata_opt: Option[ByteVector] = None,
nextTrampolineOnion_opt: Option[OnionRoutingPacket] = None,
customTlvs: Set[GenericTlv] = Set.empty) extends Recipient {
// Feature bit used by the legacy trampoline feature.
private val isLegacyTrampoline = features.unknown.contains(UnknownFeature(149))

override def buildPayloads(paymentHash: ByteVector32, route: Route): Either[OutgoingPaymentError, PaymentPayloads] = {
ClearRecipient.validateRoute(nodeId, route).map(_ => {
val finalPayload = nextTrampolineOnion_opt match {
case Some(trampolinePacket) if isLegacyTrampoline => NodePayload(nodeId, FinalPayload.Standard.createLegacyTrampolinePayload(route.amount, totalAmount, expiry, paymentSecret, trampolinePacket))
case Some(trampolinePacket) => NodePayload(nodeId, FinalPayload.Standard.createTrampolinePayload(route.amount, totalAmount, expiry, paymentSecret, trampolinePacket))
case None => NodePayload(nodeId, FinalPayload.Standard.createPayload(route.amount, totalAmount, expiry, paymentSecret, paymentMetadata_opt, customTlvs))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ object TrampolinePayment {
def buildOutgoingPayment(trampolineNodeId: PublicKey, invoice: Invoice, amount: MilliSatoshi, expiry: CltvExpiry, trampolinePaymentSecret: ByteVector32, attemptNumber: Int): OutgoingPayment = {
val totalAmount = invoice.amount_opt.get
val trampolineOnion = invoice match {
case invoice: Bolt11Invoice if invoice.features.hasFeature(Features.TrampolinePaymentPrototype) =>
case invoice: Bolt11Invoice if invoice.features.hasFeature(Features.TrampolinePayment) =>
val finalPayload = PaymentOnion.FinalPayload.Standard.createPayload(amount, totalAmount, expiry, invoice.paymentSecret, invoice.paymentMetadata)
val trampolinePayload = PaymentOnion.IntermediatePayload.NodeRelay.Standard(totalAmount, expiry, invoice.nodeId)
buildOnion(NodePayload(trampolineNodeId, trampolinePayload) :: NodePayload(invoice.nodeId, finalPayload) :: Nil, invoice.paymentHash, None).toOption.get
Expand Down
Loading

0 comments on commit 4f57a4f

Please sign in to comment.