Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
9 changes: 9 additions & 0 deletions eclair-core/src/main/resources/reference.conf
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,15 @@ eclair {
fee-base-msat = 1000
fee-proportional-millionths = 100
}
// If set to true, we will try to relay trampoline payments that use a routing hint or a blinded path even if
// that means we will earn less than our usual relay fee. This can be helpful when service providers set a
// somewhat large fee in the routing hint (or blinded path) for the wallet that they operate to earn money when
// their users receive payments. We don't want those payments to fail too often because they reach the maximum
// fee budget set by users, so we may accept earning less than expected.
// Note that when this is enabled, `min-trampoline` only controls whether we accept relaying the payment: it
// doesn't guarantee our revenue anymore, since the whole trampoline fee we receive may be spent paying the
// downstream nodes, in which case we relay for free.
trampoline-ignore-local-fees = false
// By default, if the fees values are updated in configuration, they will automatically be applied to existing
// channels on restart (except if custom per-node settings have been defined, in which case they take precedence).
// There is a performance hit on restart if there is a large number (> 100s) of channels, so it can be disabled.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,7 @@ object NodeParams extends Logging {
publicChannelFees = getRelayFees(config.getConfig("relay.fees.public-channels")),
privateChannelFees = getRelayFees(config.getConfig("relay.fees.private-channels")),
minTrampolineFees = getRelayFees(config.getConfig("relay.fees.min-trampoline")),
trampolineIgnoreLocalFees = config.getBoolean("relay.fees.trampoline-ignore-local-fees"),
resetExistingChannels = config.getBoolean("relay.fees.reset-existing-channels"),
enforcementDelay = FiniteDuration(config.getDuration("relay.fees.enforcement-delay").getSeconds, TimeUnit.SECONDS),
asyncPaymentsParams = AsyncPaymentsParams(asyncPaymentHoldTimeoutBlocks, asyncPaymentCancelSafetyBeforeTimeoutBlocks),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,15 +141,15 @@ object NodeRelay {
}

/** Compute route params that honor our fee and cltv requirements. */
private def computeRouteParams(nodeParams: NodeParams, amountIn: MilliSatoshi, expiryIn: CltvExpiry, amountOut: MilliSatoshi, expiryOut: CltvExpiry): RouteParams = {
private def computeRouteParams(nodeParams: NodeParams, amountIn: MilliSatoshi, expiryIn: CltvExpiry, amountOut: MilliSatoshi, expiryOut: CltvExpiry, includeLocalChannelCost: Boolean): RouteParams = {
val routeParams = nodeParams.routerConf.pathFindingExperimentConf.getRandomConf().getDefaultRouteParams
routeParams.copy(
boundaries = routeParams.boundaries.copy(
maxFeeProportional = 0, // we disable percent-based max fee calculation, we're only interested in collecting our node fee
maxFeeFlat = amountIn - amountOut,
maxCltv = expiryIn - expiryOut
),
includeLocalChannelCost = true
includeLocalChannelCost = includeLocalChannelCost
)
}

Expand Down Expand Up @@ -357,7 +357,11 @@ class NodeRelay private(nodeParams: NodeParams,
accountable0
}
val paymentCfg = SendPaymentConfig(relayId, relayId, None, paymentHash, recipient.nodeId, upstream, None, None, storeInDb = false, publishEvent = false, recordPathFindingMetrics = true, accountable)
val routeParams = computeRouteParams(nodeParams, upstream.amountIn, upstream.expiryIn, amountOut, expiryOut)
// We may allow dipping into our local channel fees to ensure that payments can be relayed. We will earn less than
// expected, but it's a better UX for users and allows other LSPs to earn a fee when their users receive payments
// (by setting a somewhat large fee in the routing hint or the blinded path).
val dipIntoTrampolineFees = nodeParams.relayParams.trampolineIgnoreLocalFees && recipient.extraEdges.nonEmpty
val routeParams = computeRouteParams(nodeParams, upstream.amountIn, upstream.expiryIn, amountOut, expiryOut, includeLocalChannelCost = !dipIntoTrampolineFees)
// If the next node is using trampoline, we assume that they support MPP.
val useMultiPart = recipient.features.hasFeature(Features.BasicMultiPartPayment) || packetOut_opt.nonEmpty
val payFsmAdapters = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ object Relayer extends Logging {
case class RelayParams(publicChannelFees: RelayFees,
privateChannelFees: RelayFees,
minTrampolineFees: RelayFees,
trampolineIgnoreLocalFees: Boolean,
resetExistingChannels: Boolean,
enforcementDelay: FiniteDuration,
asyncPaymentsParams: AsyncPaymentsParams,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -252,11 +252,16 @@ class MultiPartPaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig,
case _: Upstream.Local => 0.msat // no local fees when we are the origin of the payment
case u: Upstream.Hot.Channel => u.amountIn - paymentSent.amountWithFees
case _: Upstream.Hot.Trampoline =>
// in case of a relayed payment, we need to take into account the fee of the first channels
paymentSent.parts.collect {
// NB: the route attribute will always be defined here
case p@PaymentPart(_, _, _, Some(route), _) => route.head.fee(p.amountWithFees)
}.sum
// In case of a relayed payment, we need to take into account the fee of the first channels, unless we
// explicitly chose to relay without collecting that fee.
if (request.routeParams.includeLocalChannelCost) {
paymentSent.parts.collect {
// NB: the route attribute will always be defined here
case p@PaymentPart(_, _, _, Some(route), _) => route.head.fee(p.amountWithFees)
}.sum
} else {
0 msat

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I figure we could maybe set a minimum relay fee here (and in PaymentLifeCycle), WDYT?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then I think we should repurpose the relay.fees.min-trampoline section of eclair.conf for that, shouldn't we?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've tried a different approach in 88102e1, which lets us more flexibly configure a threshold trampoline fee that's different from the channel fees.

Note that this is somewhat unrelated to the line of code you're commenting on, which just computes local vs total path fees for reporting.

}
}
paymentSent.feesPaid + localFees
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -420,11 +420,20 @@ class PaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, router: A
case _: Upstream.Local => 0.msat // no local fees when we are the origin of the payment
case u: Upstream.Hot.Channel => u.amountIn - paymentSent.amountWithFees
case _: Upstream.Hot.Trampoline =>
// in case of a relayed payment, we need to take into account the fee of the first channels
paymentSent.parts.collect {
// NB: the route attribute will always be defined here
case p@PaymentPart(_, _, _, Some(route), _) => route.head.fee(p.amountWithFees)
}.sum
// In case of a relayed payment, we need to take into account the fee of the first channels, unless we
// explicitly chose to relay without collecting that fee.
val includeLocalChannelCost = request match {
case request: SendPaymentToNode => request.routeParams.includeLocalChannelCost
case _: SendPaymentToRoute => true
}
if (includeLocalChannelCost) {
paymentSent.parts.collect {
// NB: the route attribute will always be defined here
case p@PaymentPart(_, _, _, Some(route), _) => route.head.fee(p.amountWithFees)
}.sum
} else {
0 msat
}
}
paymentSent.feesPaid + localFees
case Left(paymentFailed) =>
Expand Down
26 changes: 8 additions & 18 deletions eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala
Original file line number Diff line number Diff line change
Expand Up @@ -176,15 +176,10 @@ object TestConstants {
perNodeFeerateTolerance = Map.empty
),
relayParams = RelayParams(
publicChannelFees = RelayFees(
feeBase = 546000 msat,
feeProportionalMillionths = 10),
privateChannelFees = RelayFees(
feeBase = 547000 msat,
feeProportionalMillionths = 20),
minTrampolineFees = RelayFees(
feeBase = 548000 msat,
feeProportionalMillionths = 30),
publicChannelFees = RelayFees(feeBase = 546000 msat, feeProportionalMillionths = 10),
privateChannelFees = RelayFees(feeBase = 547000 msat, feeProportionalMillionths = 20),
minTrampolineFees = RelayFees(feeBase = 548000 msat, feeProportionalMillionths = 30),
trampolineIgnoreLocalFees = false,
resetExistingChannels = true,
enforcementDelay = 10 minutes,
asyncPaymentsParams = AsyncPaymentsParams(1008, CltvExpiryDelta(144)),
Expand Down Expand Up @@ -405,15 +400,10 @@ object TestConstants {
perNodeFeerateTolerance = Map.empty
),
relayParams = RelayParams(
publicChannelFees = RelayFees(
feeBase = 546000 msat,
feeProportionalMillionths = 10),
privateChannelFees = RelayFees(
feeBase = 547000 msat,
feeProportionalMillionths = 20),
minTrampolineFees = RelayFees(
feeBase = 548000 msat,
feeProportionalMillionths = 30),
publicChannelFees = RelayFees(feeBase = 546000 msat, feeProportionalMillionths = 10),
privateChannelFees = RelayFees(feeBase = 547000 msat, feeProportionalMillionths = 20),
minTrampolineFees = RelayFees(feeBase = 548000 msat, feeProportionalMillionths = 30),
trampolineIgnoreLocalFees = false,
resetExistingChannels = true,
enforcementDelay = 10 minutes,
asyncPaymentsParams = AsyncPaymentsParams(1008, CltvExpiryDelta(144)),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package fr.acinq.eclair.integration.basic.fixtures.composite

import akka.actor.ActorSystem
import akka.testkit.TestKit
import fr.acinq.eclair.NodeParams
import fr.acinq.eclair.integration.basic.fixtures.{FixtureUtils, MinimalNodeFixture}

case class FourNodesFixture private(system: ActorSystem,
alice: MinimalNodeFixture,
bob: MinimalNodeFixture,
carol: MinimalNodeFixture,
dave: MinimalNodeFixture) {
implicit val implicitSystem: ActorSystem = system

def cleanup(): Unit = {
TestKit.shutdownActorSystem(alice.system)
TestKit.shutdownActorSystem(bob.system)
TestKit.shutdownActorSystem(carol.system)
TestKit.shutdownActorSystem(dave.system)
TestKit.shutdownActorSystem(system)
}
}

object FourNodesFixture {
def apply(aliceParams: NodeParams, bobParams: NodeParams, carolParams: NodeParams, daveParams: NodeParams, testName: String): FourNodesFixture = {
FourNodesFixture(
system = ActorSystem("system-test", FixtureUtils.actorSystemConfig(testName)),
alice = MinimalNodeFixture(aliceParams, testName),
bob = MinimalNodeFixture(bobParams, testName),
carol = MinimalNodeFixture(carolParams, testName),
dave = MinimalNodeFixture(daveParams, testName),
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/*
* Copyright 2026 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.integration.basic.payment

import akka.actor.typed.scaladsl.adapter.ClassicActorRefOps
import akka.testkit.TestProbe
import com.softwaremill.quicklens.ModifyPimp
import fr.acinq.bitcoin.scalacompat.{ByteVector32, SatoshiLong}
import fr.acinq.eclair.channel.NORMAL
import fr.acinq.eclair.db.IncomingPaymentStatus
import fr.acinq.eclair.integration.basic.fixtures.MinimalNodeFixture.{connect, getChannelState, getRouterData, knownFundingTxs, nodeParamsFor, openChannel, watcherAutopilot}
import fr.acinq.eclair.integration.basic.fixtures.composite.FourNodesFixture
import fr.acinq.eclair.payment.receive.MultiPartHandler.ReceiveStandardPayment
import fr.acinq.eclair.payment.relay.Relayer.RelayFees
import fr.acinq.eclair.payment.send.PaymentInitiator.SendTrampolinePayment
import fr.acinq.eclair.payment._
import fr.acinq.eclair.testutils.FixtureSpec
import fr.acinq.eclair.{CltvExpiryDelta, MilliSatoshiLong, nodeFee}
import org.scalatest.concurrent.IntegrationPatience
import org.scalatest.{Tag, TestData}
import scodec.bits.HexStringSyntax

import scala.concurrent.duration.DurationInt

/**
* Trampoline payments where the recipient is a wallet that can only be reached through a routing hint containing a
* large fee, which is how service providers usually get paid when their users receive payments.
*
* Alice --- Bob --- Carol --- Dave
*
* Alice is a wallet sending a trampoline payment, Bob is the trampoline node, Carol is Dave's service provider and
* Dave is a wallet receiving the payment.
*/
class TrampolinePaymentSpec extends FixtureSpec with IntegrationPatience {

type FixtureParam = FourNodesFixture

private val IgnoreLocalFees = "ignore_local_fees"

private val amount = 100_000_000 msat
// Bob charges a large fee on the channels it uses to relay the payment.
private val bobRelayFees = RelayFees(feeBase = 1000 msat, feeProportionalMillionths = 2000)
// Carol advertises a larger fee than its actual relay fee in the routing hints of Dave's invoices.
private val carolHintFees = RelayFees(feeBase = 1000 msat, feeProportionalMillionths = 1000)
// The TrampolinePaymentLifecycle test actor pays a 0.2% trampoline fee on its first attempt and doubles it on every
// retry: we use a value that only allows one attempt.
private val maxTrampolineFee = 250_000 msat

override def createFixture(testData: TestData): FixtureParam = {
val aliceParams = nodeParamsFor("alice", ByteVector32(hex"b4acd47335b25ab7b84b8c020997b12018592bb4631b868762154d77fa8b93a3"))
.modify(_.channelConf.channelFlags.announceChannel).setTo(false)
val bobParams = nodeParamsFor("bob", ByteVector32(hex"7620226fec887b0b2ebe76492e5a3fd3eb0e47cd3773263f6a81b59a704dc492"))
.modify(_.channelConf.channelFlags.announceChannel).setTo(false)
.modify(_.channelConf.expiryDelta).setTo(CltvExpiryDelta(48))
.modify(_.enableTrampolinePayment).setTo(true)
.modify(_.relayParams.privateChannelFees).setTo(bobRelayFees)
.modify(_.relayParams.trampolineIgnoreLocalFees).setTo(testData.tags.contains(IgnoreLocalFees))
val carolParams = nodeParamsFor("carol", ByteVector32(hex"ebd5a5d3abfb3ef73731eb3418d918f247445183180522674666db98a66411cc"))
.modify(_.channelConf.channelFlags.announceChannel).setTo(false)
.modify(_.channelConf.expiryDelta).setTo(CltvExpiryDelta(48))
val daveParams = nodeParamsFor("dave", ByteVector32(hex"9451f9b0f0b1b6b6ba4ba4b4a0eb0af4d5e1b8ffa8bd0a04a72af9b64a1f7c58"))
.modify(_.channelConf.channelFlags.announceChannel).setTo(false)

val f = FourNodesFixture(aliceParams, bobParams, carolParams, daveParams, testData.name)
import f._

Seq(alice, bob, carol, dave).foreach(_.watcher.setAutoPilot(watcherAutopilot(knownFundingTxs(alice, bob, carol, dave))))

connect(alice, bob)
connect(bob, carol)
connect(carol, dave)
val channelId_ab = openChannel(alice, bob, 500_000 sat).channelId
val channelId_bc = openChannel(bob, carol, 500_000 sat).channelId
val channelId_cd = openChannel(carol, dave, 500_000 sat).channelId
eventually {
assert(Seq((alice, channelId_ab), (bob, channelId_ab), (bob, channelId_bc), (carol, channelId_bc), (carol, channelId_cd), (dave, channelId_cd)).forall {
case (node, channelId) => getChannelState(node, channelId) == NORMAL
})
}

f
}

override def cleanupFixture(fixture: FixtureParam): Unit = {
fixture.cleanup()
}

/** Dave creates an invoice containing a routing hint for its channel with Carol, where Carol inflated its relay fee. */
private def createInvoiceWithExpensiveHint(f: FixtureParam): Bolt11Invoice = {
import f._
val sender = TestProbe("sender")
val hint = eventually {
getRouterData(dave).privateChannels.values.head.toIncomingExtraHop.get
}.copy(feeBase = carolHintFees.feeBase, feeProportionalMillionths = carolHintFees.feeProportionalMillionths)
sender.send(dave.paymentHandler, ReceiveStandardPayment(sender.ref.toTyped, Some(amount), Left("trampoline to a wallet"), extraHops = List(List(hint))))
sender.expectMsgType[Bolt11Invoice]
}

private def sendTrampolinePayment(f: FixtureParam, invoice: Bolt11Invoice): Either[PaymentFailed, PaymentSent] = {
import f._
val sender = TestProbe("sender")
val routeParams = alice.routeParams
.modify(_.boundaries.maxFeeFlat).setTo(maxTrampolineFee)
.modify(_.boundaries.maxFeeProportional).setTo(0.0)
sender.send(alice.paymentInitiator, SendTrampolinePayment(sender.ref, invoice, bob.nodeId, routeParams, blockUntilComplete = true))
sender.expectMsgType[PaymentEvent](60 seconds) match {
case e: PaymentSent => Right(e)
case e: PaymentFailed => Left(e)
case e => fail(s"unexpected payment event: $e")
}
}

test("relay trampoline payment to a wallet behind an expensive routing hint", Tag(IgnoreLocalFees)) { f =>
import f._

val invoice = createInvoiceWithExpensiveHint(f)
val relayListener = TestProbe("relay-listener")
bob.system.eventStream.subscribe(relayListener.ref, classOf[TrampolinePaymentRelayed])

val paymentSent = sendTrampolinePayment(f, invoice) match {
case Right(paymentSent) => paymentSent
case Left(paymentFailed) => fail(s"payment should not have failed: $paymentFailed")
}
assert(paymentSent.recipientAmount == amount)
assert(paymentSent.feesPaid == amount * 0.002)
assert(dave.nodeParams.db.payments.getIncomingPayment(invoice.paymentHash).exists(_.status.isInstanceOf[IncomingPaymentStatus.Received]))

// Bob relayed the payment, but earned less than the relay fee of its outgoing channel: it paid Carol's inflated
// routing hint fee out of the trampoline fee it received.
val relayed = relayListener.expectMsgType[TrampolinePaymentRelayed]
assert(relayed.paymentHash == invoice.paymentHash)
assert(relayed.amountOut >= amount + nodeFee(carolHintFees, amount))
assert(relayed.relayFee > 0.msat)
assert(relayed.relayFee < nodeFee(bobRelayFees, amount))
}

test("fail to relay trampoline payment when the routing hint fee is too high") { f =>
import f._

val invoice = createInvoiceWithExpensiveHint(f)
// Bob cannot relay the payment: once Carol's routing hint fee is paid, the trampoline fee doesn't cover the relay
// fee of Bob's outgoing channel.
sendTrampolinePayment(f, invoice) match {
case Right(paymentSent) => fail(s"payment should have failed: $paymentSent")
case Left(_) => ()
}
assert(dave.nodeParams.db.payments.getIncomingPayment(invoice.paymentHash).exists(_.status == IncomingPaymentStatus.Pending))
}

}
Loading