What changed, and why it matters
This commit adds a new Lightning protocol feature bit called 'phoenix_zero_reserve' and simplifies how Eclair handles a Phoenix-specific taproot channel type. It is a feature addition and refactoring change, not a fix for a known security bug. There is no evidence in the commit or supplied references that this resolves an active vulnerability.
No immediate security action required. Reviewers may want to confirm that the new PhoenixZeroReserve feature bit does not conflict with any reserved or future-standard bits, and that the simplified channel type still enforces intended reserve policies in channel negotiation.
Security signals we found
New feature bit registration (mandatory bit 128) for zero-reserve channels
Channel type simplification removing scidAlias/zeroConf variants for Phoenix taproot channels
No security-relevant keywords in commit title or message
No CVE, advisory, or vendor security disclosure referenced
No changes to validation, authentication, or cryptographic logic observed
Evidence from the diff
The change introduces Features.PhoenixZeroReserve (mandatory bit 128) as an InitFeature/ChannelTypeFeature/PermanentChannelFeature, and converts SimpleTaprootChannelsPhoenix from a case class with scidAlias/zeroConf flags into a singleton channel type that always includes PhoenixZeroReserve and SimpleTaprootChannelsPhoenix features. It updates pattern matching and tests accordingly. The commit message frames this as a feature addition, not a security patch.
Changed components
eclair-core/src/main/scala/fr/acinq/eclair/Features.scalaeclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelFeatures.scalaeclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scalaInteractiveTxBuilderSpecWaitForAcceptChannelStateSpecNormalSplicesStateSpecInspect captured patch +34 / −31
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Features.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Features.scala
index 5104f77..12e5127 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/Features.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/Features.scala
@@ -312,6 +312,11 @@ object Features {
val mandatory = 60
}
+ case object PhoenixZeroReserve extends Feature with InitFeature with ChannelTypeFeature with PermanentChannelFeature {
+ val rfcName = "phoenix_zero_reserve"
+ val mandatory = 128
+ }
+
/** This feature bit indicates that the node is a mobile wallet that can be woken up via push notifications. */
case object WakeUpNotificationClient extends Feature with InitFeature {
val rfcName = "wake_up_notification_client"
@@ -401,7 +406,8 @@ object Features {
AsyncPaymentPrototype,
SplicePrototype,
OnTheFlyFunding,
- FundingFeeCredit
+ FundingFeeCredit,
+ PhoenixZeroReserve
)
// Features may depend on other features, as specified in Bolt 9.
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 ad4799e..7ca5b53 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
@@ -118,17 +118,6 @@ object ChannelTypes {
override def commitmentFormat: CommitmentFormat = ZeroFeeHtlcTxAnchorOutputsCommitmentFormat
override def toString: String = s"anchor_outputs_zero_fee_htlc_tx${if (scidAlias) "+scid_alias" else ""}${if (zeroConf) "+zeroconf" else ""}"
}
- case class SimpleTaprootChannelsPhoenix(scidAlias: Boolean = false, zeroConf: Boolean = false) extends SupportedChannelType {
- /** Known channel-type features */
- override def features: Set[ChannelTypeFeature] = Set(
- if (scidAlias) Some(Features.ScidAlias) else None,
- if (zeroConf) Some(Features.ZeroConf) else None,
- Some(Features.SimpleTaprootChannelsPhoenix),
- ).flatten
- override def paysDirectlyToWallet: Boolean = false
- override def commitmentFormat: CommitmentFormat = PhoenixSimpleTaprootChannelCommitmentFormat
- override def toString: String = s"simple_taproot_channel_phoenix${if (scidAlias) "+scid_alias" else ""}${if (zeroConf) "+zeroconf" else ""}"
- }
case class SimpleTaprootChannelsStaging(scidAlias: Boolean = false, zeroConf: Boolean = false) extends SupportedChannelType {
/** Known channel-type features */
override def features: Set[ChannelTypeFeature] = Set(
@@ -145,6 +134,16 @@ object ChannelTypes {
override def features: Set[InitFeature] = featureBits.activated.keySet
override def toString: String = s"0x${featureBits.toByteVector.toHex}"
}
+
+ // Phoenix uses custom channel types, that we may remove in the future.
+ case object SimpleTaprootChannelsPhoenix extends SupportedChannelType {
+ /** Known channel-type features */
+ override def features: Set[ChannelTypeFeature] = Set(Features.PhoenixZeroReserve, Features.SimpleTaprootChannelsPhoenix)
+ override def paysDirectlyToWallet: Boolean = false
+ override def commitmentFormat: CommitmentFormat = PhoenixSimpleTaprootChannelCommitmentFormat
+ override def toString: String = "phoenix_simple_taproot_channel"
+ }
+
// @formatter:on
private val features2ChannelType: Map[Features[_ <: InitFeature], SupportedChannelType] = Set(
@@ -164,10 +163,7 @@ object ChannelTypes {
AnchorOutputsZeroFeeHtlcTx(zeroConf = true),
AnchorOutputsZeroFeeHtlcTx(scidAlias = true),
AnchorOutputsZeroFeeHtlcTx(scidAlias = true, zeroConf = true),
- SimpleTaprootChannelsPhoenix(),
- SimpleTaprootChannelsPhoenix(zeroConf = true),
- SimpleTaprootChannelsPhoenix(scidAlias = true),
- SimpleTaprootChannelsPhoenix(scidAlias = true, zeroConf = true),
+ SimpleTaprootChannelsPhoenix,
SimpleTaprootChannelsStaging(),
SimpleTaprootChannelsStaging(zeroConf = true),
SimpleTaprootChannelsStaging(scidAlias = true),
@@ -188,7 +184,7 @@ object ChannelTypes {
if (canUse(Features.SimpleTaprootChannelsStaging)) {
SimpleTaprootChannelsStaging(scidAlias, zeroConf)
} else if (canUse(Features.SimpleTaprootChannelsPhoenix)) {
- SimpleTaprootChannelsPhoenix(scidAlias, zeroConf)
+ SimpleTaprootChannelsPhoenix
} else if (canUse(Features.AnchorOutputsZeroFeeHtlcTx)) {
AnchorOutputsZeroFeeHtlcTx(scidAlias, zeroConf)
} else if (canUse(Features.AnchorOutputs)) {
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 ccbbabc..fbad954 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
@@ -30,6 +30,7 @@ import fr.acinq.eclair.blockchain._
import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher
import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient
+import fr.acinq.eclair.channel.ChannelTypes.SimpleTaprootChannelsPhoenix
import fr.acinq.eclair.channel.Commitments.PostRevocationAction
import fr.acinq.eclair.channel.Helpers.Closing.MutualClose
import fr.acinq.eclair.channel.Helpers.Syncing.SyncResult
@@ -1113,8 +1114,8 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
// We only support updating phoenix channels to taproot: we ignore other attempts at upgrading the
// commitment format and will simply apply the previous commitment format.
val nextCommitmentFormat = msg.channelType_opt match {
- case Some(channelType: ChannelTypes.SimpleTaprootChannelsPhoenix) if parentCommitment.commitmentFormat == UnsafeLegacyAnchorOutputsCommitmentFormat =>
- log.info(s"accepting upgrade to $channelType during splice from commitment format ${parentCommitment.commitmentFormat}")
+ case Some(ChannelTypes.SimpleTaprootChannelsPhoenix) if parentCommitment.commitmentFormat == UnsafeLegacyAnchorOutputsCommitmentFormat =>
+ log.info(s"accepting upgrade to SimpleTaprootChannelsPhoenix during splice from commitment format ${parentCommitment.commitmentFormat}")
PhoenixSimpleTaprootChannelCommitmentFormat
case Some(channelType) =>
log.info(s"rejecting upgrade to $channelType during splice from commitment format ${parentCommitment.commitmentFormat}")
@@ -1181,7 +1182,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
// We only support updating phoenix channels to taproot: we ignore other attempts at upgrading the
// commitment format and will simply apply the previous commitment format.
val nextCommitmentFormat = msg.channelType_opt match {
- case Some(_: ChannelTypes.SimpleTaprootChannelsPhoenix) if parentCommitment.commitmentFormat == UnsafeLegacyAnchorOutputsCommitmentFormat => PhoenixSimpleTaprootChannelCommitmentFormat
+ case Some(ChannelTypes.SimpleTaprootChannelsPhoenix) if parentCommitment.commitmentFormat == UnsafeLegacyAnchorOutputsCommitmentFormat => PhoenixSimpleTaprootChannelCommitmentFormat
case _ => parentCommitment.commitmentFormat
}
val fundingParams = InteractiveTxParams(
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala
index a5302ae..9d27115 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala
@@ -825,7 +825,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit
val utxosA = Seq(150_000 sat)
val fundingB1 = 90_000 sat
val utxosB = Seq(130_000 sat)
- withFixture(ChannelTypes.SimpleTaprootChannelsPhoenix(), fundingA1, utxosA, fundingB1, utxosB, FeeratePerKw(1000 sat), 660 sat, 0, RequireConfirmedInputs(forLocal = true, forRemote = true)) { f =>
+ withFixture(ChannelTypes.SimpleTaprootChannelsPhoenix, fundingA1, utxosA, fundingB1, utxosB, FeeratePerKw(1000 sat), 660 sat, 0, RequireConfirmedInputs(forLocal = true, forRemote = true)) { f =>
import f._
val probe = TestProbe()
@@ -2934,7 +2934,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit
test("invalid commit_sig (taproot)") {
val (alice2bob, bob2alice) = (TestProbe(), TestProbe())
val wallet = new SingleKeyOnChainWallet()
- val params = createFixtureParams(ChannelTypes.SimpleTaprootChannelsPhoenix(), 100_000 sat, 25_000 sat, FeeratePerKw(5000 sat), 330 sat, 0)
+ val params = createFixtureParams(ChannelTypes.SimpleTaprootChannelsPhoenix, 100_000 sat, 25_000 sat, FeeratePerKw(5000 sat), 330 sat, 0)
val alice = params.spawnTxBuilderAlice(wallet)
val bob = params.spawnTxBuilderBob(wallet)
alice ! Start(alice2bob.ref)
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptChannelStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptChannelStateSpec.scala
index 5f79b82..d82fe3f 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptChannelStateSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptChannelStateSpec.scala
@@ -110,7 +110,7 @@ class WaitForAcceptChannelStateSpec extends TestKitBaseClass with FixtureAnyFunS
test("recv AcceptChannel (simple taproot channels phoenix)", Tag(ChannelStateTestsTags.OptionSimpleTaprootPhoenix)) { f =>
import f._
val accept = bob2alice.expectMsgType[AcceptChannel]
- assert(accept.channelType_opt.contains(ChannelTypes.SimpleTaprootChannelsPhoenix()))
+ assert(accept.channelType_opt.contains(ChannelTypes.SimpleTaprootChannelsPhoenix))
assert(accept.commitNonce_opt.isDefined)
bob2alice.forward(alice)
awaitCond(alice.stateName == WAIT_FOR_FUNDING_INTERNAL)
@@ -121,7 +121,7 @@ class WaitForAcceptChannelStateSpec extends TestKitBaseClass with FixtureAnyFunS
test("recv AcceptChannel (simple taproot channels outputs, missing nonce)", Tag(ChannelStateTestsTags.OptionSimpleTaprootPhoenix)) { f =>
import f._
val accept = bob2alice.expectMsgType[AcceptChannel]
- assert(accept.channelType_opt.contains(ChannelTypes.SimpleTaprootChannelsPhoenix()))
+ assert(accept.channelType_opt.contains(ChannelTypes.SimpleTaprootChannelsPhoenix))
assert(accept.commitNonce_opt.isDefined)
bob2alice.forward(alice, accept.copy(tlvStream = accept.tlvStream.copy(records = accept.tlvStream.records.filterNot(_.isInstanceOf[ChannelTlv.NextLocalNonceTlv]))))
alice2bob.expectMsg(Error(accept.temporaryChannelId, MissingCommitNonce(accept.temporaryChannelId, TxId(ByteVector32.Zeroes), 0).getMessage))
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala
index 5f8c3db..ff1645a 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala
@@ -757,7 +757,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
import f._
val htlcs = setupHtlcs(f)
- initiateSplice(f, spliceIn_opt = Some(SpliceIn(400_000 sat)), channelType_opt = Some(ChannelTypes.SimpleTaprootChannelsPhoenix()))
+ initiateSplice(f, spliceIn_opt = Some(SpliceIn(400_000 sat)), channelType_opt = Some(ChannelTypes.SimpleTaprootChannelsPhoenix))
assert(alice.commitments.active.head.commitmentFormat == PhoenixSimpleTaprootChannelCommitmentFormat)
assert(alice.commitments.active.last.commitmentFormat == UnsafeLegacyAnchorOutputsCommitmentFormat)
resolveHtlcs(f, htlcs)
@@ -767,7 +767,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
import f._
val htlcs = setupHtlcs(f)
- initiateSplice(f, spliceIn_opt = Some(SpliceIn(400_000 sat)), channelType_opt = Some(ChannelTypes.SimpleTaprootChannelsPhoenix()))
+ initiateSplice(f, spliceIn_opt = Some(SpliceIn(400_000 sat)), channelType_opt = Some(ChannelTypes.SimpleTaprootChannelsPhoenix))
assert(alice.commitments.active.head.commitmentFormat == ZeroFeeHtlcTxAnchorOutputsCommitmentFormat)
assert(alice.commitments.active.last.commitmentFormat == ZeroFeeHtlcTxAnchorOutputsCommitmentFormat)
resolveHtlcs(f, htlcs)
@@ -3724,7 +3724,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
val htlcs = setupHtlcs(f)
// Our first splice upgrades the channel to taproot.
- val fundingTx1 = initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat)), channelType_opt = Some(ChannelTypes.SimpleTaprootChannelsPhoenix()))
+ val fundingTx1 = initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat)), channelType_opt = Some(ChannelTypes.SimpleTaprootChannelsPhoenix))
checkWatchConfirmed(f, fundingTx1)
// The first splice confirms on Bob's side.
@@ -3819,7 +3819,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
val htlcs = setupHtlcs(f)
// Our splice upgrades the channel to taproot.
- val spliceTx = initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat)), channelType_opt = Some(ChannelTypes.SimpleTaprootChannelsPhoenix()))
+ val spliceTx = initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat)), channelType_opt = Some(ChannelTypes.SimpleTaprootChannelsPhoenix))
assert(alice.commitments.active.head.commitmentFormat == PhoenixSimpleTaprootChannelCommitmentFormat)
assert(alice.commitments.active.last.commitmentFormat == UnsafeLegacyAnchorOutputsCommitmentFormat)
checkWatchConfirmed(f, spliceTx)
@@ -3860,7 +3860,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
val htlcs = setupHtlcs(f)
// Our splice upgrades the channel to taproot.
- val spliceTx = initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat)), channelType_opt = Some(ChannelTypes.SimpleTaprootChannelsPhoenix()))
+ val spliceTx = initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat)), channelType_opt = Some(ChannelTypes.SimpleTaprootChannelsPhoenix))
assert(alice.commitments.active.head.commitmentFormat == PhoenixSimpleTaprootChannelCommitmentFormat)
assert(alice.commitments.active.last.commitmentFormat == UnsafeLegacyAnchorOutputsCommitmentFormat)
checkWatchConfirmed(f, spliceTx)
@@ -3911,7 +3911,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
val htlcs = setupHtlcs(f)
// Our splice upgrades the channel to taproot.
- val spliceTx = initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat)), channelType_opt = Some(ChannelTypes.SimpleTaprootChannelsPhoenix()))
+ val spliceTx = initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat)), channelType_opt = Some(ChannelTypes.SimpleTaprootChannelsPhoenix))
assert(alice.commitments.active.head.commitmentFormat == PhoenixSimpleTaprootChannelCommitmentFormat)
assert(alice.commitments.active.last.commitmentFormat == UnsafeLegacyAnchorOutputsCommitmentFormat)
checkWatchConfirmed(f, spliceTx)
@@ -3947,7 +3947,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
val htlcs = setupHtlcs(f)
// Our splice upgrades the channel to taproot.
- val spliceTx = initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat)), channelType_opt = Some(ChannelTypes.SimpleTaprootChannelsPhoenix()))
+ val spliceTx = initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat)), channelType_opt = Some(ChannelTypes.SimpleTaprootChannelsPhoenix))
assert(alice.commitments.active.head.commitmentFormat == PhoenixSimpleTaprootChannelCommitmentFormat)
assert(alice.commitments.active.last.commitmentFormat == UnsafeLegacyAnchorOutputsCommitmentFormat)
assert(alice2blockchain.expectMsgType[WatchPublished].txId == spliceTx.txid)
Why this scored 19/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.