Auto-refresh relay fees from conf can be disabled (#3260)
What changed, and why it matters
This commit adds an optional setting that lets node operators disable an automatic fee refresh for existing payment channels when Eclair restarts. By default, the old behavior is preserved (fees are still refreshed). The change is framed as a performance improvement for operators with hundreds of channels, not as a security fix. There is no direct evidence in the commit that this resolves a vulnerability or that an attacker can exploit the old behavior.
No security action required. Treat as a normal feature/performance configuration change. Operators with many channels may evaluate setting `reset-existing-channels = false` if restart performance is an issue, while understanding that config fee changes will then not automatically apply to existing channels.
Security signals we found
No security framing in commit message or release notes
Change is purely opt-in performance optimization
Default behavior unchanged, preserving prior fee-update semantics
No input validation, cryptographic, or authorization changes
No references to CVEs, advisories, or security researchers
Evidence from the diff
The patch introduces eclair.relay.fees.reset-existing-channels (default true). When true, on channel restore Eclair compares the current config’s relay fees/CLTV delta against the persisted channelUpdate and sends CMD_UPDATE_RELAY_FEE if they differ. When false, that comparison and command are skipped, avoiding a restart-time cost for nodes with many channels. The setting is threaded through NodeParams into RelayParams and used in Channel.scala restore logic. Tests are updated to assert the refreshed update is later re-announced to the peer.
Changed components
eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scalaeclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scalaeclair-core/src/main/scala/fr/acinq/eclair/payment/relay/Relayer.scalaeclair-core/src/main/resources/reference.confInspect captured patch +31 / −13
diff --git a/docs/release-notes/eclair-vnext.md b/docs/release-notes/eclair-vnext.md
index 006d8a9..4aed4af 100644
--- a/docs/release-notes/eclair-vnext.md
+++ b/docs/release-notes/eclair-vnext.md
@@ -9,6 +9,12 @@
We remove the code used to support legacy channels that don't use anchor outputs or taproot.
If you still have such channels, eclair won't start: you will need to close those channels, and will only be able to update eclair once they have been successfully closed.
+### Auto-refresh relay fees from configuration can be disabled
+
+There is a performance hit on restart if there is a large number (> 100s) of channels, so it can be disabled with a new `eclair.relay.fees.reset-existing-channels` setting.
+
+The default behavior is unchanged.
+
### Channel lifecyle events rework
Eclair emits several events during a channel lifecycle, which can be received by plugins or through the websocket.
diff --git a/eclair-core/src/main/resources/reference.conf b/eclair-core/src/main/resources/reference.conf
index 2e456cf..d46515f 100644
--- a/eclair-core/src/main/resources/reference.conf
+++ b/eclair-core/src/main/resources/reference.conf
@@ -241,6 +241,10 @@ eclair {
fee-base-msat = 1000
fee-proportional-millionths = 100
}
+ // 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.
+ reset-existing-channels = true
// Delay enforcement of channel fee updates
enforcement-delay = 10 minutes
}
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 be25030..f1e392b 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala
@@ -638,6 +638,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")),
+ resetExistingChannels = config.getBoolean("relay.fees.reset-existing-channels"),
enforcementDelay = FiniteDuration(config.getDuration("relay.fees.enforcement-delay").getSeconds, TimeUnit.SECONDS),
asyncPaymentsParams = AsyncPaymentsParams(asyncPaymentHoldTimeoutBlocks, asyncPaymentCancelSafetyBeforeTimeoutBlocks),
peerReputationConfig = Reputation.Config(
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala
index 5ab4177..7b25860 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala
@@ -452,13 +452,15 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
goto(CLOSING) using closing
case normal: DATA_NORMAL =>
context.system.eventStream.publish(ShortChannelIdAssigned(self, normal.channelId, normal.lastAnnouncement_opt, normal.aliases, remoteNodeId))
- // we check the configuration because the values for channel_update may have changed while eclair was down
- val fees = getRelayFees(nodeParams, remoteNodeId, normal.commitments.announceChannel)
- if (fees.feeBase != normal.channelUpdate.feeBaseMsat ||
- fees.feeProportionalMillionths != normal.channelUpdate.feeProportionalMillionths ||
- nodeParams.channelConf.expiryDelta != normal.channelUpdate.cltvExpiryDelta) {
- log.debug("refreshing channel_update due to configuration changes")
- self ! CMD_UPDATE_RELAY_FEE(ActorRef.noSender, fees.feeBase, fees.feeProportionalMillionths)
+ if (nodeParams.relayParams.resetExistingChannels) {
+ // we refresh the relay fee settings if eclair has been restarted with a new conf
+ val fees = getRelayFees(nodeParams, remoteNodeId, normal.commitments.announceChannel)
+ if (fees.feeBase != normal.channelUpdate.feeBaseMsat ||
+ fees.feeProportionalMillionths != normal.channelUpdate.feeProportionalMillionths ||
+ nodeParams.channelConf.expiryDelta != normal.channelUpdate.cltvExpiryDelta) {
+ log.debug("refreshing channel_update due to configuration changes")
+ self ! CMD_UPDATE_RELAY_FEE(ActorRef.noSender, fees.feeBase, fees.feeProportionalMillionths)
+ }
}
// we need to periodically re-send channel updates, otherwise channel will be considered stale and get pruned by network
// we take into account the date of the last update so that we don't send superfluous updates when we restart the app
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 fe44843..06dec87 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,
+ resetExistingChannels: Boolean,
enforcementDelay: FiniteDuration,
asyncPaymentsParams: AsyncPaymentsParams,
peerReputationConfig: Reputation.Config,
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 3a43f34..0a8f0a0 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala
@@ -180,6 +180,7 @@ object TestConstants {
minTrampolineFees = RelayFees(
feeBase = 548000 msat,
feeProportionalMillionths = 30),
+ resetExistingChannels = true,
enforcementDelay = 10 minutes,
asyncPaymentsParams = AsyncPaymentsParams(1008, CltvExpiryDelta(144)),
peerReputationConfig = Reputation.Config(enabled = true, 1 day, 10 minutes),
@@ -372,6 +373,7 @@ object TestConstants {
minTrampolineFees = RelayFees(
feeBase = 548000 msat,
feeProportionalMillionths = 30),
+ resetExistingChannels = true,
enforcementDelay = 10 minutes,
asyncPaymentsParams = AsyncPaymentsParams(1008, CltvExpiryDelta(144)),
peerReputationConfig = Reputation.Config(enabled = true, 2 day, 20 minutes),
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/RestoreSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/RestoreSpec.scala
index db69a0d..1050912 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/RestoreSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/RestoreSpec.scala
@@ -124,11 +124,11 @@ class RestoreSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Chan
val newAlice: TestFSMRef[ChannelState, ChannelData, Channel] = TestFSMRef(new Channel(newConfig, Alice.channelKeys(), aliceWallet, Bob.nodeParams.nodeId, alice2blockchain.ref, alice2relayer.ref, FakeTxPublisherFactory(alice2blockchain)), alicePeer.ref)
newAlice ! INPUT_RESTORED(oldStateData)
- val u1 = channelUpdateListener.expectMsgType[ChannelUpdateParametersChanged]
- assert(!Announcements.areSameRelayParams(u1.channelUpdate, oldStateData.channelUpdate))
- assert(u1.channelUpdate.feeBaseMsat == newConfig.relayParams.privateChannelFees.feeBase)
- assert(u1.channelUpdate.feeProportionalMillionths == newConfig.relayParams.privateChannelFees.feeProportionalMillionths)
- assert(u1.channelUpdate.cltvExpiryDelta == newConfig.channelConf.expiryDelta)
+ val cup = channelUpdateListener.expectMsgType[ChannelUpdateParametersChanged]
+ assert(!Announcements.areSameRelayParams(cup.channelUpdate, oldStateData.channelUpdate))
+ assert(cup.channelUpdate.feeBaseMsat == newConfig.relayParams.privateChannelFees.feeBase)
+ assert(cup.channelUpdate.feeProportionalMillionths == newConfig.relayParams.privateChannelFees.feeProportionalMillionths)
+ assert(cup.channelUpdate.cltvExpiryDelta == newConfig.channelConf.expiryDelta)
newAlice ! INPUT_RECONNECTED(alice2bob.ref, aliceInit, bobInit)
bob ! INPUT_RECONNECTED(bob2alice.ref, bobInit, aliceInit)
@@ -136,7 +136,9 @@ class RestoreSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Chan
bob2alice.expectMsgType[ChannelReestablish]
alice2bob.forward(bob)
bob2alice.forward(newAlice)
- alice2bob.expectMsgType[ChannelUpdate]
+ val u1 = alice2bob.expectMsgType[ChannelUpdate]
+ assert(cup.channelUpdate.feeBaseMsat == u1.feeBaseMsat)
+ assert(cup.channelUpdate.feeProportionalMillionths == u1.feeProportionalMillionths)
bob2alice.expectMsgType[ChannelUpdate]
alice2bob.expectNoMessage()
bob2alice.expectNoMessage()
Why this scored 18/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.