diff --git a/eclair-core/src/main/resources/reference.conf b/eclair-core/src/main/resources/reference.conf index 833c60cf6a..760300df8f 100644 --- a/eclair-core/src/main/resources/reference.conf +++ b/eclair-core/src/main/resources/reference.conf @@ -244,10 +244,19 @@ eclair { fee-base-msat = 1000 fee-proportional-millionths = 100 } - // Minimum fees for trampoline relays + // Minimum *total* fee budget for trampoline relays (including fees for the whole path that must be found to reach + // the next trampoline node). min-trampoline { + fee-base-msat = 2000 + fee-proportional-millionths = 400 + } + // Minimum trampoline fees that must be collected when relaying trampoline payments: the difference between those + // fees and min-trampoline above will be allocated to the rest of the payment path(s). + // It may make sense to use smaller values here than what is used for channel fees to ensure that more payments + // succeed, even when the recipient's LSP collects a large fee in their routing hint (or blinded path). + min-local-trampoline { fee-base-msat = 1000 - fee-proportional-millionths = 100 + fee-proportional-millionths = 200 } // 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). diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala index f435ffaf72..0f0c8b4752 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala @@ -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")), + minLocalTrampolineFees = getRelayFees(config.getConfig("relay.fees.min-local-trampoline")), resetExistingChannels = config.getBoolean("relay.fees.reset-existing-channels"), enforcementDelay = FiniteDuration(config.getDuration("relay.fees.enforcement-delay").getSeconds, TimeUnit.SECONDS), asyncPaymentsParams = AsyncPaymentsParams(asyncPaymentHoldTimeoutBlocks, asyncPaymentCancelSafetyBeforeTimeoutBlocks), diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/NodeRelay.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/NodeRelay.scala index 643ebac6a2..cb8c7812b2 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/NodeRelay.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/NodeRelay.scala @@ -39,7 +39,7 @@ import fr.acinq.eclair.payment.send.PaymentLifecycle.SendPaymentToNode import fr.acinq.eclair.payment.send._ import fr.acinq.eclair.reputation.Reputation import fr.acinq.eclair.reputation.ReputationRecorder.GetConfidence -import fr.acinq.eclair.router.Router.{ChannelHop, HopRelayParams, Route, RouteParams} +import fr.acinq.eclair.router.Router.{ChannelHop, HopRelayParams, Route} import fr.acinq.eclair.router.{BalanceTooLow, RouteNotFound} import fr.acinq.eclair.wire.protocol.PaymentOnion.IntermediatePayload import fr.acinq.eclair.wire.protocol._ @@ -140,19 +140,6 @@ 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 = { - 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 - ) - } - /** If we fail to relay a payment, we may want to attempt on-the-fly funding if it makes sense. */ private def shouldAttemptOnTheFlyFunding(nodeParams: NodeParams, recipientFeatures_opt: Option[Features[InitFeature]], failures: Seq[PaymentFailure])(implicit context: ActorContext[Command]): Boolean = { val featureOk = Features.canUseFeature(nodeParams.features.initFeatures(), recipientFeatures_opt.getOrElse(Features.empty), Features.OnTheFlyFunding) @@ -357,7 +344,23 @@ 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) + val defaultRouteParams = nodeParams.routerConf.pathFindingExperimentConf.getRandomConf().getDefaultRouteParams + val routeParams = defaultRouteParams.copy( + boundaries = defaultRouteParams.boundaries.copy( + maxFeeProportional = 0, // we disable percent-based max fee calculation, we're only interested in collecting our node fee + maxFeeFlat = if (recipient.extraEdges.isEmpty) { + // The payment doesn't contain any routing hint, so we'll enforce our local channel fees. + upstream.amountIn - amountOut + } else { + // We 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). + (upstream.amountIn - amountOut - nodeFee(nodeParams.relayParams.minLocalTrampolineFees, amountOut)).max(0 msat) + }, + maxCltv = upstream.expiryIn - expiryOut + ), + includeLocalChannelCost = recipient.extraEdges.isEmpty + ) // 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 = { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/Relayer.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/Relayer.scala index 06dec87653..4021458635 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/Relayer.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/Relayer.scala @@ -145,6 +145,7 @@ object Relayer extends Logging { case class RelayParams(publicChannelFees: RelayFees, privateChannelFees: RelayFees, minTrampolineFees: RelayFees, + minLocalTrampolineFees: RelayFees, resetExistingChannels: Boolean, enforcementDelay: FiniteDuration, asyncPaymentsParams: AsyncPaymentsParams, diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/MultiPartPaymentLifecycle.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/MultiPartPaymentLifecycle.scala index ecc9b7fb40..addd5289e8 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/MultiPartPaymentLifecycle.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/MultiPartPaymentLifecycle.scala @@ -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 + } } paymentSent.feesPaid + localFees } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/PaymentLifecycle.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/PaymentLifecycle.scala index 1470fa829b..28006e257c 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/PaymentLifecycle.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/PaymentLifecycle.scala @@ -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) => diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala index 8931ef2e26..d40a7127e5 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala @@ -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), + minLocalTrampolineFees = RelayFees(feeBase = 546000 msat, feeProportionalMillionths = 10), resetExistingChannels = true, enforcementDelay = 10 minutes, asyncPaymentsParams = AsyncPaymentsParams(1008, CltvExpiryDelta(144)), @@ -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), + minLocalTrampolineFees = RelayFees(feeBase = 546000 msat, feeProportionalMillionths = 10), resetExistingChannels = true, enforcementDelay = 10 minutes, asyncPaymentsParams = AsyncPaymentsParams(1008, CltvExpiryDelta(144)), diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/composite/FourNodesFixture.scala b/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/composite/FourNodesFixture.scala new file mode 100644 index 0000000000..3e6675f756 --- /dev/null +++ b/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/composite/FourNodesFixture.scala @@ -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), + ) + } +} diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/payment/TrampolinePaymentSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/payment/TrampolinePaymentSpec.scala new file mode 100644 index 0000000000..8b954b5b59 --- /dev/null +++ b/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/payment/TrampolinePaymentSpec.scala @@ -0,0 +1,165 @@ +/* + * 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.minLocalTrampolineFees).setToIf(!testData.tags.contains(IgnoreLocalFees))(RelayFees(feeBase = 1000 msat, feeProportionalMillionths = 2000)) + .modify(_.relayParams.minLocalTrampolineFees).setToIf(testData.tags.contains(IgnoreLocalFees))(RelayFees(feeBase = 0 msat, feeProportionalMillionths = 0)) + 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)) + } + +}