Fix minor incompatibilities with feature branches (#3148)
What changed, and why it matters
This is a small internal maintenance patch for the Eclair Lightning node. It changes an experimental message code number to avoid clashing with another in-development feature, and it starts recording which previous transaction inputs were spent by confirmed funding transactions. There is no direct security fix for a currently exploitable bug; it is preparation/cleanup for feature branches.
No urgent action. Treat as normal development/maintenance merge. If running a build from the affected feature branch, ensure peers are on compatible TLV numbering and codec versions to avoid deserialization failures.
Security signals we found
TLV type collision avoided between experimental features
New spentInputs field added to confirmed funding state for double-spend detection in feature branches
Serialization codec updated for channel data version 5
No patch to a live vulnerability, no bounds-check or authorization change
Evidence from the diff
The commit does two things: (1) renumbers a custom TLV used in TxAddInput from type 1107 to 1111 because 1107/1109 are already used by an experimental swap-in-potentiam branch, preventing TLV type collisions; (2) adds a spentInputs field (Seq[OutPoint]) to ConfirmedFundingTx and persists it in channel codecs v4/v5 so feature branches can detect double-spends across interactive-tx sessions earlier. The change is additive and backward-incompatible at the serialization layer only for the in-progress v5 codec; tests are updated accordingly.
Changed components
eclair-core channel data model (LocalFundingStatus.ConfirmedFundingTx)interactive-tx TLV handling (TxAddInputTlv.PrevTxOut)channel serialization codecs version 4 and 5channel funding confirmation handlersInspect captured patch +13 / −10
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala
index 95b4602..2d86d74 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala
@@ -458,7 +458,7 @@ object LocalFundingStatus {
case class ZeroconfPublishedFundingTx(tx: Transaction, localSigs_opt: Option[TxSignatures], liquidityPurchase_opt: Option[LiquidityAds.PurchaseBasicInfo]) extends UnconfirmedFundingTx with Locked {
override val signedTx_opt: Option[Transaction] = Some(tx)
}
- case class ConfirmedFundingTx(txOut: TxOut, shortChannelId: RealShortChannelId, localSigs_opt: Option[TxSignatures], liquidityPurchase_opt: Option[LiquidityAds.PurchaseBasicInfo]) extends LocalFundingStatus with Locked {
+ case class ConfirmedFundingTx(spentInputs: Seq[OutPoint], txOut: TxOut, shortChannelId: RealShortChannelId, localSigs_opt: Option[TxSignatures], liquidityPurchase_opt: Option[LiquidityAds.PurchaseBasicInfo]) extends LocalFundingStatus with Locked {
override val signedTx_opt: Option[Transaction] = None
}
}
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonFundingHandlers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonFundingHandlers.scala
index eea86d6..0cdfb2a 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonFundingHandlers.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonFundingHandlers.scala
@@ -87,7 +87,7 @@ trait CommonFundingHandlers extends CommonHandlers {
d.commitments.all.find(_.fundingTxId == w.tx.txid) match {
case Some(c) =>
val scid = RealShortChannelId(w.blockHeight, w.txIndex, c.fundingInput.index.toInt)
- val fundingStatus = ConfirmedFundingTx(w.tx.txOut(c.fundingInput.index.toInt), scid, d.commitments.localFundingSigs(w.tx.txid), d.commitments.liquidityPurchase(w.tx.txid))
+ val fundingStatus = ConfirmedFundingTx(w.tx.txIn.map(_.outPoint), w.tx.txOut(c.fundingInput.index.toInt), scid, d.commitments.localFundingSigs(w.tx.txid), d.commitments.liquidityPurchase(w.tx.txid))
// When a splice transaction confirms, it double-spends all the commitment transactions that only applied to the
// previous funding transaction. Our peer cannot publish the corresponding revoked commitments anymore, so we can
// clean-up the htlc data that we were storing for the matching penalty transactions.
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelTypes4.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelTypes4.scala
index c69621e..04f8e46 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelTypes4.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelTypes4.scala
@@ -124,8 +124,9 @@ private[channel] object ChannelTypes4 {
case class ConfirmedFundingTx(tx: Transaction, shortChannelId: RealShortChannelId, localSigs_opt: Option[TxSignatures], liquidityPurchase_opt: Option[LiquidityAds.PurchaseBasicInfo]) extends LocalFundingStatus {
override def migrate(commitmentFormat: CommitmentFormat): channel.LocalFundingStatus.ConfirmedFundingTx = {
+ val spentInputs = tx.txIn.map(_.outPoint)
val txOut = tx.txOut(shortChannelId.outputIndex)
- channel.LocalFundingStatus.ConfirmedFundingTx(txOut, shortChannelId, localSigs_opt, liquidityPurchase_opt)
+ channel.LocalFundingStatus.ConfirmedFundingTx(spentInputs, txOut, shortChannelId, localSigs_opt, liquidityPurchase_opt)
}
}
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version5/ChannelCodecs5.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version5/ChannelCodecs5.scala
index 956db33..12c28c2 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version5/ChannelCodecs5.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version5/ChannelCodecs5.scala
@@ -226,8 +226,10 @@ private[channel] object ChannelCodecs5 {
.typecase(0x01, partiallySignedSharedTransactionCodec)
.typecase(0x02, fullySignedSharedTransactionCodec)
+ private val spentInputscodec: Codec[Seq[OutPoint]] = listOfN(uint16, outPointCodec).xmap(_.toSeq, _.toList)
+
private val localFundingStatusCodec: Codec[LocalFundingStatus] = discriminated[LocalFundingStatus].by(uint8)
- .typecase(0x04, (txOutCodec :: realshortchannelid :: optional(bool8, lengthDelimited(txSignaturesCodec)) :: optional(bool8, liquidityPurchaseCodec)).as[LocalFundingStatus.ConfirmedFundingTx])
+ .typecase(0x04, (spentInputscodec :: txOutCodec :: realshortchannelid :: optional(bool8, lengthDelimited(txSignaturesCodec)) :: optional(bool8, liquidityPurchaseCodec)).as[LocalFundingStatus.ConfirmedFundingTx])
.typecase(0x03, (txCodec :: optional(bool8, lengthDelimited(txSignaturesCodec)) :: optional(bool8, liquidityPurchaseCodec)).as[LocalFundingStatus.ZeroconfPublishedFundingTx])
.typecase(0x02, (signedSharedTransactionCodec :: blockHeight :: fundingParamsCodec :: optional(bool8, liquidityPurchaseCodec)).as[LocalFundingStatus.DualFundedUnconfirmedFundingTx])
.typecase(0x01, optional(bool8, txCodec).as[LocalFundingStatus.SingleFundedUnconfirmedFundingTx])
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/InteractiveTxTlv.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/InteractiveTxTlv.scala
index 9f0f9e3..8450634 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/InteractiveTxTlv.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/InteractiveTxTlv.scala
@@ -50,7 +50,7 @@ object TxAddInputTlv {
val txAddInputTlvCodec: Codec[TlvStream[TxAddInputTlv]] = tlvStream(discriminated[TxAddInputTlv].by(varint)
// Note that we actually encode as a tx_hash to be consistent with other lightning messages.
.typecase(UInt64(1105), tlvField(txIdAsHash.as[SharedInputTxId]))
- .typecase(UInt64(1107), PrevTxOut.codec)
+ .typecase(UInt64(1111), PrevTxOut.codec)
)
}
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala
index 28611e0..b315f66 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala
@@ -499,7 +499,7 @@ object CommitmentsSpec {
val localCommit = LocalCommit(0, CommitmentSpec(Set.empty, feeRatePerKw, toLocal, toRemote), randomTxId(), IndividualSignature(ByteVector64.Zeroes), Nil)
val remoteCommit = RemoteCommit(0, CommitmentSpec(Set.empty, feeRatePerKw, toRemote, toLocal), randomTxId(), randomKey().publicKey)
val localFundingStatus = announcement_opt match {
- case Some(ann) => LocalFundingStatus.ConfirmedFundingTx(fundingTxOut, ann.shortChannelId, None, None)
+ case Some(ann) => LocalFundingStatus.ConfirmedFundingTx(Nil, fundingTxOut, ann.shortChannelId, None, None)
case None => LocalFundingStatus.SingleFundedUnconfirmedFundingTx(None)
}
Commitments(
@@ -524,7 +524,7 @@ object CommitmentsSpec {
val localCommit = LocalCommit(0, CommitmentSpec(Set.empty, FeeratePerKw(0 sat), toLocal, toRemote), randomTxId(), IndividualSignature(ByteVector64.Zeroes), Nil)
val remoteCommit = RemoteCommit(0, CommitmentSpec(Set.empty, FeeratePerKw(0 sat), toRemote, toLocal), randomTxId(), randomKey().publicKey)
val localFundingStatus = announcement_opt match {
- case Some(ann) => LocalFundingStatus.ConfirmedFundingTx(fundingTxOut, ann.shortChannelId, None, None)
+ case Some(ann) => LocalFundingStatus.ConfirmedFundingTx(Nil, fundingTxOut, ann.shortChannelId, None, None)
case None => LocalFundingStatus.SingleFundedUnconfirmedFundingTx(None)
}
Commitments(
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentPacketSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentPacketSpec.scala
index 785cd09..f216d20 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentPacketSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentPacketSpec.scala
@@ -754,7 +754,7 @@ object PaymentPacketSpec {
val localChanges = LocalChanges(Nil, Nil, Nil)
val remoteChanges = RemoteChanges(Nil, Nil, Nil)
val localFundingStatus = announcement_opt match {
- case Some(ann) => LocalFundingStatus.ConfirmedFundingTx(fundingTx.txOut.head, ann.shortChannelId, None, None)
+ case Some(ann) => LocalFundingStatus.ConfirmedFundingTx(Nil, fundingTx.txOut.head, ann.shortChannelId, None, None)
case None => LocalFundingStatus.SingleFundedUnconfirmedFundingTx(None)
}
val channelFlags = ChannelFlags(announceChannel = announcement_opt.nonEmpty)
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/router/RouterSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/router/RouterSpec.scala
index db201ce..cd8b6e8 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/router/RouterSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/router/RouterSpec.scala
@@ -1102,7 +1102,7 @@ class RouterSpec extends BaseRouterSpec {
}
// The second channel is announced and moves from the private channels to the public channels.
- val fundingConfirmed = LocalFundingStatus.ConfirmedFundingTx(TxOut(100_000 sat, Nil), scid2, None, None)
+ val fundingConfirmed = LocalFundingStatus.ConfirmedFundingTx(Nil, TxOut(100_000 sat, Nil), scid2, None, None)
val commitments3 = commitments2.updateLocalFundingStatus(commitments2.latest.fundingTxId, fundingConfirmed, None)(akka.event.NoLogging).toOption.get._1
assert(commitments3.channelId == commitments2.channelId)
sender.send(router, LocalChannelUpdate(sender.ref, commitments3.channelId, aliases2, x.publicKey, Some(AnnouncedCommitment(commitments3.latest.commitment, announcement2)), update2, commitments3))
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala
index c9424a3..dbc563c 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala
@@ -223,7 +223,7 @@ class LightningMessageCodecsSpec extends AnyFunSuite {
TxAddInput(channelId2, UInt64(0), Some(tx2), 2, 0) -> hex"0042 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 0000000000000000 0100 0200000000010142180a8812fc79a3da7fb2471eff3e22d7faee990604c2ba7f2fc8dfb15b550a0200000000feffffff030f241800000000001976a9146774040642a78ca3b8b395e70f8391b21ec026fc88ac4a155801000000001600148d2e0b57adcb8869e603fd35b5179caf053361253b1d010000000000160014e032f4f4b9f8611df0d30a20648c190c263bbc33024730440220506005aa347f5b698542cafcb4f1a10250aeb52a609d6fd67ef68f9c1a5d954302206b9bb844343f4012bccd9d08a0f5430afb9549555a3252e499be7df97aae477a012103976d6b3eea3de4b056cd88cdfd50a22daf121e0fb5c6e45ba0f40e1effbd275a00000000 00000002 00000000",
TxAddInput(channelId1, UInt64(561), Some(tx1), 0, 0) -> hex"0042 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000231 00f7 020000000001014ade359c5deb7c1cde2e94f401854658f97d7fa31c17ce9a831db253120a0a410100000017160014eb9a5bd79194a23d19d6ec473c768fb74f9ed32cffffffff021ca408000000000017a914946118f24bb7b37d5e9e39579e4a411e70f5b6a08763e703000000000017a9143638b2602d11f934c04abc6adb1494f69d1f14af8702473044022059ddd943b399211e4266a349f26b3289979e29f9b067792c6cfa8cc5ae25f44602204d627a5a5b603d0562e7969011fb3d64908af90a3ec7c876eaa9baf61e1958af012102f5188df1da92ed818581c29778047800ed6635788aa09d9469f7d17628f7323300000000 00000000 00000000",
TxAddInput(channelId1, UInt64(561), OutPoint(tx1, 1), 5) -> hex"0042 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000231 0000 00000001 00000005 fd0451201f2ec025a33e39ef8e177afcdc1adc855bf128dc906182255aeb64efa825f106",
- TxAddInput(channelId1, UInt64(561), None, 1, 0xfffffffdL, TlvStream(TxAddInputTlv.PrevTxOut(tx2.txid, 22_549_834 sat, hex"00148d2e0b57adcb8869e603fd35b5179caf05336125"))) -> hex"0042 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000231 0000 00000001 fffffffd fd04533efc7aa8845f192959202c1b7ff704e7cbddded463c05e844676a94ccb4bed69f1000000000158154a00148d2e0b57adcb8869e603fd35b5179caf05336125",
+ TxAddInput(channelId1, UInt64(561), None, 1, 0xfffffffdL, TlvStream(TxAddInputTlv.PrevTxOut(tx2.txid, 22_549_834 sat, hex"00148d2e0b57adcb8869e603fd35b5179caf05336125"))) -> hex"0042 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000231 0000 00000001 fffffffd fd04573efc7aa8845f192959202c1b7ff704e7cbddded463c05e844676a94ccb4bed69f1000000000158154a00148d2e0b57adcb8869e603fd35b5179caf05336125",
TxAddOutput(channelId1, UInt64(1105), 2047 sat, hex"00149357014afd0ccd265658c9ae81efa995e771f472") -> hex"0043 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000451 00000000000007ff 0016 00149357014afd0ccd265658c9ae81efa995e771f472",
TxAddOutput(channelId1, UInt64(1105), 2047 sat, hex"00149357014afd0ccd265658c9ae81efa995e771f472", TlvStream(Set.empty[TxAddOutputTlv], Set(GenericTlv(UInt64(301), hex"2a")))) -> hex"0043 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000451 00000000000007ff 0016 00149357014afd0ccd265658c9ae81efa995e771f472 fd012d012a",
TxRemoveInput(channelId2, UInt64(561)) -> hex"0044 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 0000000000000231",
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.