Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2e854e3
Add limited support for Blip18 inbound fees
rorp Oct 20, 2024
62f6647
Merge branch 'master' into blip18_inbound_fees
rorp Oct 25, 2024
5f9e17d
Fix bugs
rorp Oct 25, 2024
b1c77e8
Merge branch 'master' into blip18_inbound_fees
rorp Nov 29, 2024
58d663e
Merge commit '7e7ad4549eab8b7b5341225a7b5ba73a746c2b4d' into blip18_i…
rorp Dec 4, 2024
966a7b3
Merge commit '4df8cd079490e937f0d7ee8a20d6cd4f95e6b6d9' into blip18_i…
rorp Mar 17, 2025
799e5fd
bLIP-18 inbound routing fees
rorp Jun 9, 2025
985cb92
Merge commit '992ec8cc0dcb8437684cb3fc162b8be04f78170a' into blip18
rorp Sep 17, 2025
10705ed
Merge commit '9830aa605b31530921b3a171055b8e134287be31' into blip18
rorp Oct 29, 2025
a7999f9
blip18 weighted
rorp Jan 7, 2026
0a180b9
Merge pull request #1 from rorp/blip18_weights
rorp Apr 12, 2026
93b0a33
Merge commit 'ec8c63b215b98148887237e3f050342a6ca61f4c' into blip18
rorp May 30, 2026
1679f3a
Peroformance fixes
rorp Jul 4, 2026
663f225
Cleanup
rorp Jul 4, 2026
8db1181
Bundle bLIP-18 routing flags into Blip18Params
rorp Jul 5, 2026
16a0318
Fix bLIP-18 inbound fee edge cases.
rorp Jul 6, 2026
8a7f23e
Cleanup
rorp Jul 6, 2026
f1be4db
Stop advertising stale bLIP-18 inbound fees
rorp Jul 6, 2026
c9599b0
Fix bLIP-18 relay edge cases and simplify plumbing
rorp Jul 6, 2026
d1163a3
Improve bLIP-18 inbound fee checks and test coverage
rorp Jul 19, 2026
bef70c3
Merge commit 'd769db070424ae0ab6c05975fd7c86dac8b3e1f4' into blip18
rorp Jul 29, 2026
ad79396
Merge commit '3dd8d2d9f6aabd5627e976d95cc5da81b304a07f' into blip18
rorp Aug 27, 2026
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ project/target
DeleteMe*.*
*~
jdbcUrlFile_*.tmp
.metals/
.vscode/
.bloop/

.DS_Store

Expand Down
87 changes: 87 additions & 0 deletions docs/release-notes/eclair-vnext.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Eclair vnext

<insert here a high-level description of the release>

## Major changes

<insert changes>

### bLIP-18 Inbound Routing Fees

Eclair now supports [bLIP-18 inbound routing fees](https://github.com/lightning/blips/pull/18) which proposes an optional
TLV for channel updates that allows node operators to set (and optionally advertise) inbound routing fee discounts, enabling
more flexible fee policies and incentivizing desired incoming traffic.

#### Configuration

| Configuration Parameter | Default Value | Description |
|--------------------------------------------------------------------------|---------------|----------------------------------------------------------------------------------------------------------------------------------------------------------|
| `eclair.router.path-finding.blip18-inbound-fees` | `false` | enables support for bLIP-18 inbound routing fees |
| `eclair.router.path-finding.exclude-channels-with-positive-inbound-fees` | `false` | enables exclusion of channels with positive inbound fees from path finding, helping to prevent `FeeInsufficient` errors and ensure more reliable routing |

The routing logic considers inbound fees during route selection if enabled. New logic is added to exclude channels with
positive inbound fees from route finding when configured. The relay and route calculation logic now computes total fees
as the sum of the regular (outbound) and inbound fees when applicable.

The wire protocol is updated to include the new TLV (type 55555) for bLIP-18 inbound fees in ChannelUpdate messages.
Code that (de)serializes channel updates now handles these new fields.

Inbound fees are stored per peer in a new database: a separate `inboundfees.sqlite` file when using sqlite, or a new
`inboundfees` schema when using postgres. When using sqlite, the built-in backup mechanism maintains a snapshot of it
named `inboundfees.sqlite.bak`, next to `eclair.sqlite.bak`: if you set inbound fees, include that file in your backups.

### API changes

<insert changes>

- `updaterelayfee` now accepts optional `--inboundFeeBaseMsat` and `--inboundFeeProportionalMillionths` parameters (both must be negative or zero, since only inbound discounts are supported). If omitted, existing inbound fees will be preserved.
- `updaterelayfee` now accepts an optional `--unsetInboundFees` flag that removes any previously advertised inbound fees. It cannot be combined with `--inboundFeeBaseMsat` or `--inboundFeeProportionalMillionths`.

### Miscellaneous improvements and bug fixes

<insert changes>

## Verifying signatures

You will need `gpg` and our release signing key E04E48E72C205463. Note that you can get it:

- from our website: https://acinq.co/pgp/drouinf2.asc
- from github user @sstone, a committer on eclair: https://api.github.com/users/sstone/gpg_keys

To import our signing key:

```sh
$ gpg --import drouinf2.asc
```

To verify the release file checksums and signatures:

```sh
$ gpg -d SHA256SUMS.asc > SHA256SUMS.stripped
$ sha256sum -c SHA256SUMS.stripped
```

## Building

Eclair builds are deterministic. To reproduce our builds, please use the following environment (*):

- Ubuntu 24.04.1
- Adoptium OpenJDK 21.0.6

Use the following command to generate the eclair-node package:

```sh
./mvnw clean install -DskipTests
```

That should generate `eclair-node/target/eclair-node-<version>-XXXXXXX-bin.zip` with sha256 checksums that match the one we provide and sign in `SHA256SUMS.asc`

(*) You may be able to build the exact same artefacts with other operating systems or versions of JDK 21, we have not tried everything.

## Upgrading

This release is fully compatible with previous eclair versions. You don't need to close your channels, just stop eclair, upgrade and restart.

## Changelog

<fill this section when publishing the release with `git log v0.12.0... --format=oneline --reverse`>
2 changes: 2 additions & 0 deletions eclair-core/src/main/resources/reference.conf
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,8 @@ eclair {
}

path-finding {
blip18-inbound-fees = false
exclude-channels-with-positive-inbound-fees = false
default {
randomize-route-selection = true // when computing a route for a payment we randomize the final selection

Expand Down
30 changes: 25 additions & 5 deletions eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import fr.acinq.eclair.message.{OnionMessages, Postman}
import fr.acinq.eclair.payment._
import fr.acinq.eclair.payment.offer.{OfferCreator, OfferManager}
import fr.acinq.eclair.payment.receive.MultiPartHandler.ReceiveStandardPayment
import fr.acinq.eclair.payment.relay.Relayer.{ChannelBalance, GetOutgoingChannels, OutgoingChannels, RelayFees}
import fr.acinq.eclair.payment.relay.Relayer.{ChannelBalance, GetOutgoingChannels, InboundFees, OutgoingChannels, RelayFees}
import fr.acinq.eclair.payment.send.PaymentInitiator._
import fr.acinq.eclair.payment.send.{ClearRecipient, OfferPayment, PaymentIdentifier}
import fr.acinq.eclair.profit.PeerScorer
Expand Down Expand Up @@ -114,6 +114,8 @@ trait Eclair {

def updateRelayFee(nodes: List[PublicKey], feeBase: MilliSatoshi, feeProportionalMillionths: Long)(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_UPDATE_RELAY_FEE]]]]

def updateRelayFee(nodes: List[PublicKey], feeBase: MilliSatoshi, feeProportionalMillionths: Long, inboundFeeBase_opt: Option[MilliSatoshi], inboundFeeProportional_opt: Option[Long], unsetInboundFees: Boolean)(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_UPDATE_RELAY_FEE]]]]

def channelsInfo(toRemoteNode_opt: Option[PublicKey])(implicit timeout: Timeout): Future[Iterable[RES_GET_CHANNEL_INFO]]

def channelInfo(channel: ApiTypes.ChannelIdentifier)(implicit timeout: Timeout): Future[CommandResponse[CMD_GET_CHANNEL_INFO]]
Expand Down Expand Up @@ -311,11 +313,29 @@ class EclairImpl(val appKit: Kit) extends Eclair with Logging with SpendFromChan
sendToChannelsTyped(channels, cmdBuilder = CMD_BUMP_FORCE_CLOSE_FEE(_, confirmationTarget))
}

override def updateRelayFee(nodes: List[PublicKey], feeBaseMsat: MilliSatoshi, feeProportionalMillionths: Long)(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_UPDATE_RELAY_FEE]]]] = {
for (nodeId <- nodes) {
appKit.nodeParams.db.peers.addOrUpdateRelayFees(nodeId, RelayFees(feeBaseMsat, feeProportionalMillionths))
override def updateRelayFee(nodes: List[PublicKey], feeBaseMsat: MilliSatoshi, feeProportionalMillionths: Long)(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_UPDATE_RELAY_FEE]]]] =
updateRelayFee(nodes, feeBaseMsat, feeProportionalMillionths, None, None, unsetInboundFees = false)

override def updateRelayFee(nodes: List[PublicKey], feeBaseMsat: MilliSatoshi, feeProportionalMillionths: Long, inboundFeeBase_opt: Option[MilliSatoshi], inboundFeeProportional_opt: Option[Long], unsetInboundFees: Boolean)(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_UPDATE_RELAY_FEE]]]] = {
if ((inboundFeeBase_opt.isDefined || inboundFeeProportional_opt.isDefined) && !appKit.nodeParams.routerConf.blip18.enableInboundFees) {
Future.failed(new IllegalArgumentException("Cannot specify inbound fees when bLIP-18 support is disabled"))
} else if (!inboundFeeBase_opt.forall(value => value.toLong >= Int.MinValue && value.toLong <= 0)) {
Future.failed(new IllegalArgumentException(s"Inbound fee base must be in the range from ${Int.MinValue} to 0"))
} else if (!inboundFeeProportional_opt.forall(value => value >= -1_000_000 && value <= 0)) {
// A proportional discount below -100% has no additional effect (total fees are floored at 0), and larger
// magnitudes could overflow the fee computation (amount * proportional) for large payment amounts.
Future.failed(new IllegalArgumentException("Inbound fee proportional millionths must be in the range from -1000000 to 0"))
} else {
val inboundFees_opt = InboundFees.fromOptions(inboundFeeBase_opt, inboundFeeProportional_opt)
for (nodeId <- nodes) {
appKit.nodeParams.db.peers.addOrUpdateRelayFees(nodeId, RelayFees(feeBaseMsat, feeProportionalMillionths))
inboundFees_opt match {
case Some(inboundFees) => appKit.nodeParams.db.inboundFees.addOrUpdateInboundFees(nodeId, inboundFees)
case _ => if (unsetInboundFees) appKit.nodeParams.db.inboundFees.unsetInboundFees(nodeId)
}
}
sendToNodes(nodes, CMD_UPDATE_RELAY_FEE(ActorRef.noSender, feeBaseMsat, feeProportionalMillionths, inboundFeeBase_opt, inboundFeeProportional_opt, if (inboundFees_opt.isDefined) false else unsetInboundFees))
}
sendToNodes(nodes, CMD_UPDATE_RELAY_FEE(ActorRef.noSender, feeBaseMsat, feeProportionalMillionths))
}

override def peers()(implicit timeout: Timeout): Future[Iterable[PeerInfo]] = for {
Expand Down
7 changes: 5 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 @@ -501,7 +501,6 @@ object NodeParams extends Logging {
experimentName = name,
experimentPercentage = config.getInt("percentage"))


def getPathFindingExperimentConf(config: Config): PathFindingExperimentConf = {
val experiments = config.root.asScala.keys.map(name => name -> getPathFindingConf(config.getConfig(name), name))
PathFindingExperimentConf(experiments.toMap)
Expand Down Expand Up @@ -716,7 +715,11 @@ object NodeParams extends Logging {
pathFindingExperimentConf = getPathFindingExperimentConf(config.getConfig("router.path-finding.experiments")),
messageRouteParams = getMessageRouteParams(config.getConfig("router.message-path-finding")),
balanceEstimateHalfLife = FiniteDuration(config.getDuration("router.balance-estimate-half-life").getSeconds, TimeUnit.SECONDS),
),
blip18 = Router.Blip18Params(
enableInboundFees = config.getBoolean("router.path-finding.blip18-inbound-fees"),
excludePositiveInboundFees = config.getBoolean("router.path-finding.exclude-channels-with-positive-inbound-fees"),
),
),
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"))),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ final case class CMD_SPLICE(replyTo: akka.actor.typed.ActorRef[CommandResponse[C
val spliceOutputs: List[TxOut] = spliceOut_opt.toList.map(s => TxOut(s.amount, s.scriptPubKey))
}
final case class CMD_BUMP_FUNDING_FEE(replyTo: akka.actor.typed.ActorRef[CommandResponse[ChannelFundingCommand]], targetFeerate: FeeratePerKw, fundingFeeBudget: Satoshi, lockTime: Long, requestFunding_opt: Option[LiquidityAds.RequestFunding]) extends ChannelFundingCommand
final case class CMD_UPDATE_RELAY_FEE(replyTo: ActorRef, feeBase: MilliSatoshi, feeProportionalMillionths: Long) extends HasReplyToCommand
final case class CMD_UPDATE_RELAY_FEE(replyTo: ActorRef, feeBase: MilliSatoshi, feeProportionalMillionths: Long, inboundFeeBase_opt: Option[MilliSatoshi] = None, inboundFeeProportionalMillionths_opt: Option[Long] = None, unsetInboundFees: Boolean = false) extends HasReplyToCommand
final case class CMD_GET_CHANNEL_STATE(replyTo: ActorRef) extends HasReplyToCommand
final case class CMD_GET_CHANNEL_DATA(replyTo: ActorRef) extends HasReplyToCommand
final case class CMD_GET_CHANNEL_INFO(replyTo: akka.actor.typed.ActorRef[RES_GET_CHANNEL_INFO]) extends Command
Expand Down
18 changes: 14 additions & 4 deletions eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import fr.acinq.eclair.channel.fund.InteractiveTxSigningSession
import fr.acinq.eclair.crypto.keymanager.{ChannelKeys, LocalCommitmentKeys, RemoteCommitmentKeys}
import fr.acinq.eclair.crypto.{NonceGenerator, ShaChain}
import fr.acinq.eclair.db.ChannelsDb
import fr.acinq.eclair.payment.relay.Relayer.RelayFees
import fr.acinq.eclair.payment.relay.Relayer.{InboundFees, RelayFees}
import fr.acinq.eclair.router.Announcements
import fr.acinq.eclair.transactions.DirectedHtlc._
import fr.acinq.eclair.transactions.Transactions._
Expand Down Expand Up @@ -345,7 +345,7 @@ object Helpers {
}
}

def channelUpdate(nodeParams: NodeParams, shortChannelId: ShortChannelId, commitments: Commitments, relayFees: RelayFees, enable: Boolean): ChannelUpdate = {
def channelUpdate(nodeParams: NodeParams, shortChannelId: ShortChannelId, commitments: Commitments, relayFees: RelayFees, enable: Boolean, inboundFees_opt: Option[InboundFees]): ChannelUpdate = {
Announcements.makeChannelUpdate(
chainHash = nodeParams.chainHash,
nodeSecret = nodeParams.privateKey,
Expand All @@ -358,6 +358,12 @@ object Helpers {
htlcMaximumMsat = maxHtlcAmount(nodeParams, commitments),
isPrivate = !commitments.announceChannel,
enable = enable,
timestamp = TimestampSecond.now(),
// We never advertise inbound fees when bLIP-18 support is disabled: the relay wouldn't honour them, so keeping
// them in our channel_update (e.g. from before the feature was disabled) would give senders a fee they cannot
// actually use. We also never advertise them on unannounced channels: bLIP-18 says we MUST NOT set inbound fees
// on private channels. This gate applies to all channel_update paths (fee updates, periodic refresh, disable, etc.).
inboundFees_opt = if (nodeParams.routerConf.blip18.enableInboundFees && commitments.announceChannel) inboundFees_opt else None
)
}

Expand Down Expand Up @@ -398,9 +404,13 @@ object Helpers {
commitments.maxHtlcValueInFlight
}

def getRelayFees(nodeParams: NodeParams, remoteNodeId: PublicKey, announceChannel: Boolean): RelayFees = {
def getRelayFees(nodeParams: NodeParams, remoteNodeId: PublicKey, announceChannel: Boolean): (RelayFees, Option[InboundFees]) = {
val defaultFees = nodeParams.relayParams.defaultFees(announceChannel)
nodeParams.db.peers.getRelayFees(remoteNodeId).getOrElse(defaultFees)
val relayFees = nodeParams.db.peers.getRelayFees(remoteNodeId).getOrElse(defaultFees)
// We only query the inbound fees database when bLIP-18 support is enabled and the channel is announced (bLIP-18
// says we MUST NOT set inbound fees on private channels), to avoid a useless db lookup otherwise.
val inboundFees_opt = if (nodeParams.routerConf.blip18.enableInboundFees && announceChannel) nodeParams.db.inboundFees.getInboundFees(remoteNodeId) else None
(relayFees, inboundFees_opt)
}

object Funding {
Expand Down
Loading