Skip to content

Commit 89facd2

Browse files
committed
bLIP-18 inbound routing fees
1 parent 966a7b3 commit 89facd2

File tree

26 files changed

+483
-82
lines changed

26 files changed

+483
-82
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,7 @@ project/target
2626
DeleteMe*.*
2727
*~
2828
jdbcUrlFile_*.tmp
29+
.metals/
30+
.vscode/
2931

3032
.DS_Store

docs/release-notes/eclair-vnext.md

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# Eclair vnext
2+
3+
<insert here a high-level description of the release>
4+
5+
## Major changes
6+
7+
<insert changes>
8+
9+
### bLIP-18 Inbound Routing Fees
10+
11+
Eclair now supports [bLIP-18 inbound routing fees](https://github.com/lightning/blips/pull/18) which proposes an optional
12+
TLV for channel updates that allows node operators to set (and optionally advertise) inbound routing fee discounts, enabling
13+
more flexible fee policies and incentivizing desired incoming traffic.
14+
15+
#### Configuration
16+
17+
| Configuration Parameter | Default Value | Description |
18+
|----------------------------------------------------------------------------------|---------------|----------------------------------------------------------------------------------------------------------------------------------------------------------|
19+
| `eclair.router.path-finding.default.blip18-inbound-fees` | `false` | enables support for bLIP-18 inbound routing fees |
20+
| `eclair.router.path-finding.default.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 |
21+
22+
The routing logic considers inbound fees during route selection if enabled. New logic is added to exclude channels with
23+
positive inbound fees from route finding when configured. The relay and route calculation logic now computes total fees
24+
as the sum of the regular (outbound) and inbound fees when applicable.
25+
26+
The wire protocol is updated to include the new TLV (0x55555) type for bLIP-18 inbound fees in ChannelUpdate messages.
27+
Code that (de)serializes channel updates now handles these new fields.
28+
29+
New database tables and migration updates for storing inbound fee information per peer.
30+
31+
### API changes
32+
33+
<insert changes>
34+
35+
- `updaterelayfee` now accepts optional `--inboundFeeBaseMsat` and `--inboundFeeProportionalMillionths` parameters. If omitted, existing inbound fees will be preserved.
36+
37+
### Miscellaneous improvements and bug fixes
38+
39+
<insert changes>
40+
41+
## Verifying signatures
42+
43+
You will need `gpg` and our release signing key E04E48E72C205463. Note that you can get it:
44+
45+
- from our website: https://acinq.co/pgp/drouinf2.asc
46+
- from github user @sstone, a committer on eclair: https://api.github.com/users/sstone/gpg_keys
47+
48+
To import our signing key:
49+
50+
```sh
51+
$ gpg --import drouinf2.asc
52+
```
53+
54+
To verify the release file checksums and signatures:
55+
56+
```sh
57+
$ gpg -d SHA256SUMS.asc > SHA256SUMS.stripped
58+
$ sha256sum -c SHA256SUMS.stripped
59+
```
60+
61+
## Building
62+
63+
Eclair builds are deterministic. To reproduce our builds, please use the following environment (*):
64+
65+
- Ubuntu 24.04.1
66+
- Adoptium OpenJDK 21.0.6
67+
68+
Use the following command to generate the eclair-node package:
69+
70+
```sh
71+
./mvnw clean install -DskipTests
72+
```
73+
74+
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`
75+
76+
(*) You may be able to build the exact same artefacts with other operating systems or versions of JDK 21, we have not tried everything.
77+
78+
## Upgrading
79+
80+
This release is fully compatible with previous eclair versions. You don't need to close your channels, just stop eclair, upgrade and restart.
81+
82+
## Changelog
83+
84+
<fill this section when publishing the release with `git log v0.12.0... --format=oneline --reverse`>

eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ import fr.acinq.eclair.message.{OnionMessages, Postman}
4444
import fr.acinq.eclair.payment._
4545
import fr.acinq.eclair.payment.offer.{OfferCreator, OfferManager}
4646
import fr.acinq.eclair.payment.receive.MultiPartHandler.ReceiveStandardPayment
47-
import fr.acinq.eclair.payment.relay.Relayer.{ChannelBalance, GetOutgoingChannels, OutgoingChannels, RelayFees}
47+
import fr.acinq.eclair.payment.relay.Relayer.{ChannelBalance, GetOutgoingChannels, InboundFees, OutgoingChannels, RelayFees}
4848
import fr.acinq.eclair.payment.send.PaymentInitiator._
4949
import fr.acinq.eclair.payment.send.{ClearRecipient, OfferPayment, PaymentIdentifier}
5050
import fr.acinq.eclair.router.Router
@@ -114,6 +114,8 @@ trait Eclair {
114114

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

117+
def updateRelayFee(nodes: List[PublicKey], feeBase: MilliSatoshi, feeProportionalMillionths: Long, inboundFeeBase_opt: Option[MilliSatoshi], inboundFeeProportional_opt: Option[Long])(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_UPDATE_RELAY_FEE]]]]
118+
117119
def channelsInfo(toRemoteNode_opt: Option[PublicKey])(implicit timeout: Timeout): Future[Iterable[RES_GET_CHANNEL_INFO]]
118120

119121
def channelInfo(channel: ApiTypes.ChannelIdentifier)(implicit timeout: Timeout): Future[CommandResponse[CMD_GET_CHANNEL_INFO]]
@@ -308,11 +310,21 @@ class EclairImpl(val appKit: Kit) extends Eclair with Logging with SpendFromChan
308310
sendToChannelsTyped(channels, cmdBuilder = CMD_BUMP_FORCE_CLOSE_FEE(_, confirmationTarget))
309311
}
310312

311-
override def updateRelayFee(nodes: List[PublicKey], feeBaseMsat: MilliSatoshi, feeProportionalMillionths: Long)(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_UPDATE_RELAY_FEE]]]] = {
312-
for (nodeId <- nodes) {
313-
appKit.nodeParams.db.peers.addOrUpdateRelayFees(nodeId, RelayFees(feeBaseMsat, feeProportionalMillionths))
313+
override def updateRelayFee(nodes: List[PublicKey], feeBaseMsat: MilliSatoshi, feeProportionalMillionths: Long)(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_UPDATE_RELAY_FEE]]]] =
314+
updateRelayFee(nodes, feeBaseMsat, feeProportionalMillionths, None, None)
315+
316+
override def updateRelayFee(nodes: List[PublicKey], feeBaseMsat: MilliSatoshi, feeProportionalMillionths: Long, inboundFeeBase_opt: Option[MilliSatoshi], inboundFeeProportional_opt: Option[Long])(implicit timeout: Timeout): Future[Map[ApiTypes.ChannelIdentifier, Either[Throwable, CommandResponse[CMD_UPDATE_RELAY_FEE]]]] = {
317+
if ((inboundFeeBase_opt.isDefined || inboundFeeProportional_opt.isDefined) && !appKit.nodeParams.routerConf.blip18InboundFees) {
318+
Future.failed(new IllegalArgumentException("Cannot specify inbound fees when bLIP-18 support is disabled"))
319+
} else {
320+
for (nodeId <- nodes) {
321+
appKit.nodeParams.db.peers.addOrUpdateRelayFees(nodeId, RelayFees(feeBaseMsat, feeProportionalMillionths))
322+
InboundFees.fromOptions(inboundFeeBase_opt, inboundFeeProportional_opt).foreach { inboundFees =>
323+
appKit.nodeParams.db.inboundFees.addOrUpdateInboundFees(nodeId, inboundFees)
324+
}
325+
}
326+
sendToNodes(nodes, CMD_UPDATE_RELAY_FEE(ActorRef.noSender, feeBaseMsat, feeProportionalMillionths, inboundFeeBase_opt, inboundFeeProportional_opt))
314327
}
315-
sendToNodes(nodes, CMD_UPDATE_RELAY_FEE(ActorRef.noSender, feeBaseMsat, feeProportionalMillionths))
316328
}
317329

318330
override def peers()(implicit timeout: Timeout): Future[Iterable[PeerInfo]] = for {

eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ final case class CMD_SPLICE(replyTo: akka.actor.typed.ActorRef[CommandResponse[C
242242
val spliceOutputs: List[TxOut] = spliceOut_opt.toList.map(s => TxOut(s.amount, s.scriptPubKey))
243243
}
244244
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
245-
final case class CMD_UPDATE_RELAY_FEE(replyTo: ActorRef, feeBase: MilliSatoshi, feeProportionalMillionths: Long) extends HasReplyToCommand
245+
final case class CMD_UPDATE_RELAY_FEE(replyTo: ActorRef, feeBase: MilliSatoshi, feeProportionalMillionths: Long, inboundFeeBase_opt: Option[MilliSatoshi] = None, inboundFeeProportionalMillionths_opt: Option[Long]= None) extends HasReplyToCommand
246246
final case class CMD_GET_CHANNEL_STATE(replyTo: ActorRef) extends HasReplyToCommand
247247
final case class CMD_GET_CHANNEL_DATA(replyTo: ActorRef) extends HasReplyToCommand
248248
final case class CMD_GET_CHANNEL_INFO(replyTo: akka.actor.typed.ActorRef[RES_GET_CHANNEL_INFO]) extends Command

eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import fr.acinq.eclair.channel.fsm.Channel.REFRESH_CHANNEL_UPDATE_INTERVAL
2929
import fr.acinq.eclair.crypto.keymanager.ChannelKeyManager
3030
import fr.acinq.eclair.crypto.{Generators, ShaChain}
3131
import fr.acinq.eclair.db.ChannelsDb
32-
import fr.acinq.eclair.payment.relay.Relayer.RelayFees
32+
import fr.acinq.eclair.payment.relay.Relayer.{InboundFees, RelayFees}
3333
import fr.acinq.eclair.router.Announcements
3434
import fr.acinq.eclair.transactions.DirectedHtlc._
3535
import fr.acinq.eclair.transactions.Scripts._
@@ -351,9 +351,9 @@ object Helpers {
351351
commitments.params.maxHtlcAmount
352352
}
353353

354-
def getRelayFees(nodeParams: NodeParams, remoteNodeId: PublicKey, announceChannel: Boolean): RelayFees = {
354+
def getRelayFees(nodeParams: NodeParams, remoteNodeId: PublicKey, announceChannel: Boolean): (RelayFees, Option[InboundFees]) = {
355355
val defaultFees = nodeParams.relayParams.defaultFees(announceChannel)
356-
nodeParams.db.peers.getRelayFees(remoteNodeId).getOrElse(defaultFees)
356+
(nodeParams.db.peers.getRelayFees(remoteNodeId).getOrElse(defaultFees), nodeParams.db.inboundFees.getInboundFees(remoteNodeId))
357357
}
358358

359359
object Funding {

eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import fr.acinq.eclair.db.PendingCommandsDb
4646
import fr.acinq.eclair.io.Peer
4747
import fr.acinq.eclair.io.Peer.LiquidityPurchaseSigned
4848
import fr.acinq.eclair.payment.relay.Relayer
49+
import fr.acinq.eclair.payment.relay.Relayer.InboundFees
4950
import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentSettlingOnChain}
5051
import fr.acinq.eclair.router.Announcements
5152
import fr.acinq.eclair.transactions.Transactions.ClosingTx
@@ -390,12 +391,13 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
390391
case normal: DATA_NORMAL =>
391392
context.system.eventStream.publish(ShortChannelIdAssigned(self, normal.channelId, normal.lastAnnouncement_opt, normal.aliases, remoteNodeId))
392393
// we check the configuration because the values for channel_update may have changed while eclair was down
393-
val fees = getRelayFees(nodeParams, remoteNodeId, normal.commitments.announceChannel)
394+
val (fees, inboundFees_opt) = getRelayFees(nodeParams, remoteNodeId, normal.commitments.announceChannel)
394395
if (fees.feeBase != normal.channelUpdate.feeBaseMsat ||
395396
fees.feeProportionalMillionths != normal.channelUpdate.feeProportionalMillionths ||
397+
inboundFees_opt != normal.channelUpdate.blip18InboundFees_opt ||
396398
nodeParams.channelConf.expiryDelta != normal.channelUpdate.cltvExpiryDelta) {
397399
log.debug("refreshing channel_update due to configuration changes")
398-
self ! CMD_UPDATE_RELAY_FEE(ActorRef.noSender, fees.feeBase, fees.feeProportionalMillionths)
400+
self ! CMD_UPDATE_RELAY_FEE(ActorRef.noSender, fees.feeBase, fees.feeProportionalMillionths, inboundFees_opt.map(_.feeBase), inboundFees_opt.map(_.feeProportionalMillionths))
399401
}
400402
// we need to periodically re-send channel updates, otherwise channel will be considered stale and get pruned by network
401403
// we take into account the date of the last update so that we don't send superfluous updates when we restart the app
@@ -825,7 +827,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
825827
log.info("announcing channelId={} on the network with shortChannelId={} for fundingTxIndex={}", d.channelId, localAnnSigs.shortChannelId, c.fundingTxIndex)
826828
// We generate a new channel_update because we can now use the scid of the announced funding transaction.
827829
val scidForChannelUpdate = Helpers.scidForChannelUpdate(Some(channelAnn), d.aliases.localAlias)
828-
val channelUpdate = Announcements.makeChannelUpdate(nodeParams, remoteNodeId, scidForChannelUpdate, d.commitments.params, d.channelUpdate.relayFees, Helpers.maxHtlcAmount(nodeParams, d.commitments), enable = true)
830+
val channelUpdate = Announcements.makeChannelUpdate(nodeParams, remoteNodeId, scidForChannelUpdate, d.commitments.params, d.channelUpdate.relayFees, Helpers.maxHtlcAmount(nodeParams, d.commitments), enable = true, d.channelUpdate.blip18InboundFees_opt)
829831
context.system.eventStream.publish(ShortChannelIdAssigned(self, d.channelId, Some(channelAnn), d.aliases, remoteNodeId))
830832
// We use goto() instead of stay() because we want to fire transitions.
831833
goto(NORMAL) using d.copy(lastAnnouncement_opt = Some(channelAnn), channelUpdate = channelUpdate) storing()
@@ -847,7 +849,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
847849
}
848850

849851
case Event(c: CMD_UPDATE_RELAY_FEE, d: DATA_NORMAL) =>
850-
val channelUpdate1 = Announcements.makeChannelUpdate(nodeParams, remoteNodeId, scidForChannelUpdate(d), d.commitments.params, Relayer.RelayFees(c.feeBase, c.feeProportionalMillionths), Helpers.maxHtlcAmount(nodeParams, d.commitments), enable = true)
852+
val channelUpdate1 = Announcements.makeChannelUpdate(nodeParams, remoteNodeId, scidForChannelUpdate(d), d.commitments.params, Relayer.RelayFees(c.feeBase, c.feeProportionalMillionths), Helpers.maxHtlcAmount(nodeParams, d.commitments), enable = true, InboundFees.fromOptions(c.inboundFeeBase_opt, c.inboundFeeProportionalMillionths_opt))
851853
log.debug(s"updating relay fees: prev={} next={}", d.channelUpdate.toStringShort, channelUpdate1.toStringShort)
852854
val replyTo = if (c.replyTo == ActorRef.noSender) sender() else c.replyTo
853855
replyTo ! RES_SUCCESS(c, d.channelId)
@@ -856,7 +858,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
856858

857859
case Event(BroadcastChannelUpdate(reason), d: DATA_NORMAL) =>
858860
val age = TimestampSecond.now() - d.channelUpdate.timestamp
859-
val channelUpdate1 = Announcements.makeChannelUpdate(nodeParams, remoteNodeId, scidForChannelUpdate(d), d.commitments.params, d.channelUpdate.relayFees, Helpers.maxHtlcAmount(nodeParams, d.commitments), enable = true)
861+
val channelUpdate1 = Announcements.makeChannelUpdate(nodeParams, remoteNodeId, scidForChannelUpdate(d), d.commitments.params, d.channelUpdate.relayFees, Helpers.maxHtlcAmount(nodeParams, d.commitments), enable = true, d.channelUpdate.blip18InboundFees_opt)
860862
reason match {
861863
case Reconnected if d.commitments.announceChannel && Announcements.areSame(channelUpdate1, d.channelUpdate) && age < REFRESH_CHANNEL_UPDATE_INTERVAL =>
862864
// we already sent an identical channel_update not long ago (flapping protection in case we keep being disconnected/reconnected)
@@ -1447,7 +1449,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
14471449
// if we have pending unsigned htlcs, then we cancel them and generate an update with the disabled flag set, that will be returned to the sender in a temporary channel failure
14481450
if (d.commitments.changes.localChanges.proposed.collectFirst { case add: UpdateAddHtlc => add }.isDefined) {
14491451
log.debug("updating channel_update announcement (reason=disabled)")
1450-
val channelUpdate1 = Announcements.makeChannelUpdate(nodeParams, remoteNodeId, scidForChannelUpdate(d), d.commitments.params, d.channelUpdate.relayFees, Helpers.maxHtlcAmount(nodeParams, d.commitments), enable = false)
1452+
val channelUpdate1 = Announcements.makeChannelUpdate(nodeParams, remoteNodeId, scidForChannelUpdate(d), d.commitments.params, d.channelUpdate.relayFees, Helpers.maxHtlcAmount(nodeParams, d.commitments), enable = false, d.channelUpdate.blip18InboundFees_opt)
14511453
// NB: the htlcs stay in the commitments.localChange, they will be cleaned up after reconnection
14521454
d.commitments.changes.localChanges.proposed.collect {
14531455
case add: UpdateAddHtlc => relayer ! RES_ADD_SETTLED(d.commitments.originChannels(add.id), add, HtlcResult.DisconnectedBeforeSigned(channelUpdate1))
@@ -2941,7 +2943,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
29412943
log.debug("emitting channel down event")
29422944
if (d.lastAnnouncement_opt.nonEmpty) {
29432945
// We tell the rest of the network that this channel shouldn't be used anymore.
2944-
val disabledUpdate = Announcements.makeChannelUpdate(nodeParams, remoteNodeId, Helpers.scidForChannelUpdate(d), d.commitments.params, d.channelUpdate.relayFees, Helpers.maxHtlcAmount(nodeParams, d.commitments), enable = false)
2946+
val disabledUpdate = Announcements.makeChannelUpdate(nodeParams, remoteNodeId, Helpers.scidForChannelUpdate(d), d.commitments.params, d.channelUpdate.relayFees, Helpers.maxHtlcAmount(nodeParams, d.commitments), enable = false, d.channelUpdate.blip18InboundFees_opt)
29452947
context.system.eventStream.publish(LocalChannelUpdate(self, d.channelId, d.aliases, remoteNodeId, d.lastAnnouncedCommitment_opt, disabledUpdate, d.commitments))
29462948
}
29472949
val lcd = LocalChannelDown(self, d.channelId, d.commitments.all.flatMap(_.shortChannelId_opt), d.aliases, remoteNodeId)
@@ -3134,7 +3136,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
31343136
if (d.channelUpdate.channelFlags.isEnabled) {
31353137
// if the channel isn't disabled we generate a new channel_update
31363138
log.debug("updating channel_update announcement (reason=disabled)")
3137-
val channelUpdate1 = Announcements.makeChannelUpdate(nodeParams, remoteNodeId, scidForChannelUpdate(d), d.commitments.params, d.channelUpdate.relayFees, Helpers.maxHtlcAmount(nodeParams, d.commitments), enable = false)
3139+
val channelUpdate1 = Announcements.makeChannelUpdate(nodeParams, remoteNodeId, scidForChannelUpdate(d), d.commitments.params, d.channelUpdate.relayFees, Helpers.maxHtlcAmount(nodeParams, d.commitments), enable = false, d.channelUpdate.blip18InboundFees_opt)
31383140
// then we update the state and replay the request
31393141
self forward c
31403142
// we use goto() to fire transitions
@@ -3147,7 +3149,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
31473149
}
31483150

31493151
private def handleUpdateRelayFeeDisconnected(c: CMD_UPDATE_RELAY_FEE, d: DATA_NORMAL) = {
3150-
val channelUpdate1 = Announcements.makeChannelUpdate(nodeParams, remoteNodeId, scidForChannelUpdate(d), d.commitments.params, Relayer.RelayFees(c.feeBase, c.feeProportionalMillionths), Helpers.maxHtlcAmount(nodeParams, d.commitments), enable = false)
3152+
val channelUpdate1 = Announcements.makeChannelUpdate(nodeParams, remoteNodeId, scidForChannelUpdate(d), d.commitments.params, Relayer.RelayFees(c.feeBase, c.feeProportionalMillionths), Helpers.maxHtlcAmount(nodeParams, d.commitments), enable = false, InboundFees.fromOptions(c.inboundFeeBase_opt, c.inboundFeeProportionalMillionths_opt))
31513153
log.debug(s"updating relay fees: prev={} next={}", d.channelUpdate.toStringShort, channelUpdate1.toStringShort)
31523154
val replyTo = if (c.replyTo == ActorRef.noSender) sender() else c.replyTo
31533155
replyTo ! RES_SUCCESS(c, d.channelId)

0 commit comments

Comments
 (0)