Select `channel_type` for automatic channel creation (#3250)
What changed, and why it matters
This commit changes how Eclair selects a Lightning channel type when opening a channel automatically. Previously, the caller had to explicitly provide a channel type. Now, if no type is provided, Eclair will automatically pick a preferred type based on what both the local node and the remote peer support. If no compatible type can be found, the channel opening is rejected. This is a feature improvement, not a security fix, and the commit message does not describe it as fixing a vulnerability.
No security action required. Review as a normal feature change. If deploying, ensure node operators understand that automatic channel type selection now defaults to anchor outputs when supported, and that explicit `channel_type` overrides this behavior.
Security signals we found
No security-relevant signals in commit message or diff
Change is a feature/enhancement for automatic channel type selection
No mention of vulnerability, CVE, bug bounty, or security researcher
No memory safety, cryptographic, or authorization changes observed
Evidence from the diff
The patch adds ChannelTypes.preferredForPublicChannels(), which picks AnchorOutputsZeroFeeHtlcTx (optionally with scidAlias) when both peers support anchor outputs. OpenChannelInterceptor.sanityCheckInitiator() now uses request.open.channelType_opt.orElse(...) to fall back to this automatic selection. The rejection message is updated to note that the channel type must be compatible with peer features. Tests are added to verify automatic selection and rejection when no compatible type exists.
Changed components
eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelFeatures.scalaeclair-core/src/main/scala/fr/acinq/eclair/io/OpenChannelInterceptor.scalaeclair-core/src/test/scala/fr/acinq/eclair/io/OpenChannelInterceptorSpec.scalaInspect captured patch +35 / −5
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelFeatures.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelFeatures.scala
index e557cee..04fda87 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelFeatures.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelFeatures.scala
@@ -116,7 +116,6 @@ object ChannelTypes {
override def commitmentFormat: CommitmentFormat = PhoenixSimpleTaprootChannelCommitmentFormat
override def toString: String = "phoenix_simple_taproot_channel"
}
-
// @formatter:on
private val features2ChannelType: Map[Features[_ <: InitFeature], SupportedChannelType] = Set(
@@ -149,4 +148,13 @@ object ChannelTypes {
case Some(proposedChannelType: SupportedChannelType) => Left(InvalidChannelType(channelId, proposedChannelType))
}
+ /** Returns our preferred channel type for public channels, if supported by our peer. */
+ def preferredForPublicChannels(localFeatures: Features[InitFeature], remoteFeatures: Features[InitFeature]): Option[SupportedChannelType] = {
+ if (Features.canUseFeature(localFeatures, remoteFeatures, Features.AnchorOutputsZeroFeeHtlcTx)) {
+ Some(AnchorOutputsZeroFeeHtlcTx(scidAlias = Features.canUseFeature(localFeatures, remoteFeatures, Features.ScidAlias)))
+ } else {
+ None
+ }
+ }
+
}
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/io/OpenChannelInterceptor.scala b/eclair-core/src/main/scala/fr/acinq/eclair/io/OpenChannelInterceptor.scala
index 8c50e39..fa0fd9b 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/io/OpenChannelInterceptor.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/io/OpenChannelInterceptor.scala
@@ -108,23 +108,23 @@ private class OpenChannelInterceptor(peer: ActorRef[Any],
}
private def sanityCheckInitiator(request: OpenChannelInitiator): Behavior[Command] = {
+ val channelType_opt = request.open.channelType_opt.orElse(ChannelTypes.preferredForPublicChannels(request.localFeatures, request.remoteFeatures))
if (request.open.fundingAmount >= Channel.MAX_FUNDING_WITHOUT_WUMBO && !request.localFeatures.hasFeature(Wumbo)) {
request.replyTo ! OpenChannelResponse.Rejected(s"fundingAmount=${request.open.fundingAmount} is too big, you must enable large channels support in 'eclair.features' to use funding above ${Channel.MAX_FUNDING_WITHOUT_WUMBO} (see eclair.conf)")
waitForRequest()
} else if (request.open.fundingAmount >= Channel.MAX_FUNDING_WITHOUT_WUMBO && !request.remoteFeatures.hasFeature(Wumbo)) {
request.replyTo ! OpenChannelResponse.Rejected(s"fundingAmount=${request.open.fundingAmount} is too big, the remote peer doesn't support wumbo")
waitForRequest()
- } else if (request.open.channelType_opt.isEmpty) {
- request.replyTo ! OpenChannelResponse.Rejected("channel_type must be provided")
+ } else if (channelType_opt.isEmpty) {
+ request.replyTo ! OpenChannelResponse.Rejected("channel_type must be provided and compatible with our peer's features")
waitForRequest()
} else {
- val channelType = request.open.channelType_opt.get
val dualFunded = Features.canUseFeature(request.localFeatures, request.remoteFeatures, Features.DualFunding)
val upfrontShutdownScript = Features.canUseFeature(request.localFeatures, request.remoteFeatures, Features.UpfrontShutdownScript)
// If we're purchasing liquidity, we expect our peer to contribute at least the amount we're purchasing, otherwise we'll cancel the funding attempt.
val expectedFundingAmount = request.open.fundingAmount + request.open.requestFunding_opt.map(_.requestedAmount).getOrElse(0 sat)
val localParams = createLocalParams(nodeParams, request.localFeatures, upfrontShutdownScript, isChannelOpener = true, paysCommitTxFees = true, dualFunded = dualFunded, expectedFundingAmount)
- peer ! Peer.SpawnChannelInitiator(request.replyTo, request.open, ChannelConfig.standard, channelType, localParams)
+ peer ! Peer.SpawnChannelInitiator(request.replyTo, request.open, ChannelConfig.standard, channelType_opt.get, localParams)
waitForRequest()
}
}
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/io/OpenChannelInterceptorSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/io/OpenChannelInterceptorSpec.scala
index 6b582ec..4d1bba9 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/io/OpenChannelInterceptorSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/io/OpenChannelInterceptorSpec.scala
@@ -282,4 +282,26 @@ class OpenChannelInterceptorSpec extends ScalaTestWithActorTestKit(ConfigFactory
eventListener.expectMessageType[ChannelAborted]
}
+ test("don't spawn a channel if we cannot find a satisfying channel type") { f =>
+ import f._
+
+ val probe = TestProbe[Any]()
+
+ // If we both support anchor outputs, it is selected by default.
+ {
+ val features = Features[InitFeature](StaticRemoteKey -> Optional, AnchorOutputsZeroFeeHtlcTx -> Optional, ChannelType -> Optional, ScidAlias -> Optional)
+ val open = Peer.OpenChannel(remoteNodeId, 500_000 sat, None, None, None, None, None, None, None)
+ openChannelInterceptor ! OpenChannelInitiator(probe.ref, remoteNodeId, open, features, features)
+ assert(peer.expectMessageType[Peer.SpawnChannelInitiator].channelType == ChannelTypes.AnchorOutputsZeroFeeHtlcTx(scidAlias = true))
+ }
+ // If our peer doesn't support anchor outputs, we can't find a satisfying channel type.
+ {
+ val localFeatures = Features[InitFeature](StaticRemoteKey -> Optional, AnchorOutputsZeroFeeHtlcTx -> Optional, ChannelType -> Optional, ScidAlias -> Optional)
+ val remoteFeatures = Features[InitFeature](StaticRemoteKey -> Optional, ChannelType -> Optional, ScidAlias -> Optional)
+ val open = Peer.OpenChannel(remoteNodeId, 500_000 sat, None, None, None, None, None, None, None)
+ openChannelInterceptor ! OpenChannelInitiator(probe.ref, remoteNodeId, open, localFeatures, remoteFeatures)
+ assert(probe.expectMessageType[OpenChannelResponse.Rejected].reason == "channel_type must be provided and compatible with our peer's features")
+ }
+ }
+
}
\ No newline at end of file
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.