Fix `LocalFundingStatus.ConfirmedFundingTx` migration (#3151)
What changed, and why it matters
This commit fixes a data migration bug in the Eclair Lightning node. When older channel data is upgraded to a newer storage format, the code now uses the actual commit transaction input to identify the channel's funding output, instead of relying on the shortChannelId. The old approach could pick the wrong output for private channels where the shortChannelId is not reliably set, potentially leading to incorrect channel state after an upgrade.
Treat as a bug-fix patch with possible operational/security side effects. Nodes upgrading from older channel codec versions should apply this fix before migration occurs. Review whether any channels were already migrated with the incorrect output and may need recovery or resync.
Security signals we found
Data migration correctness fix
Potential state corruption / wrong UTXO selection during channel codec upgrade
Private channels specifically affected
No explicit security framing by vendor
Evidence from the diff
The patch changes LocalFundingStatus.ConfirmedFundingTx.migrate in ChannelTypes4.scala. Previously it selected tx.txOut(shortChannelId.outputIndex) as the funding output. Now it accepts a commitInput: InputInfo parameter and uses commitInput.txOut. The call site passes localCommit.input. This ensures the migrated ConfirmedFundingTx stores the exact output that the commitment transaction spends, rather than inferring it from shortChannelId.outputIndex, which may be wrong for private channels. A test was updated to pass an explicit InputInfo.
Changed components
eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelTypes4.scalaeclair-core/src/test/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4Spec.scalaInspect captured patch +8 / −9
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 04f8e46..b1e9634 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
@@ -107,26 +107,25 @@ private[channel] object ChannelTypes4 {
// We removed the signed transaction when confirmed to save space when moving to channel codecs v5.
sealed trait LocalFundingStatus {
- def migrate(commitmentFormat: CommitmentFormat): channel.LocalFundingStatus
+ def migrate(commitmentFormat: CommitmentFormat, commitInput: InputInfo): channel.LocalFundingStatus
}
case class SingleFundedUnconfirmedFundingTx(signedTx_opt: Option[Transaction]) extends LocalFundingStatus {
- override def migrate(commitmentFormat: CommitmentFormat): channel.LocalFundingStatus.SingleFundedUnconfirmedFundingTx = channel.LocalFundingStatus.SingleFundedUnconfirmedFundingTx(signedTx_opt)
+ override def migrate(commitmentFormat: CommitmentFormat, commitInput: InputInfo): channel.LocalFundingStatus.SingleFundedUnconfirmedFundingTx = channel.LocalFundingStatus.SingleFundedUnconfirmedFundingTx(signedTx_opt)
}
case class DualFundedUnconfirmedFundingTx(sharedTx: SignedSharedTransaction, createdAt: BlockHeight, fundingParams: InteractiveTxParams, liquidityPurchase_opt: Option[LiquidityAds.PurchaseBasicInfo]) extends LocalFundingStatus {
- override def migrate(commitmentFormat: CommitmentFormat): channel.LocalFundingStatus.DualFundedUnconfirmedFundingTx = channel.LocalFundingStatus.DualFundedUnconfirmedFundingTx(sharedTx, createdAt, fundingParams.migrate(commitmentFormat), liquidityPurchase_opt)
+ override def migrate(commitmentFormat: CommitmentFormat, commitInput: InputInfo): channel.LocalFundingStatus.DualFundedUnconfirmedFundingTx = channel.LocalFundingStatus.DualFundedUnconfirmedFundingTx(sharedTx, createdAt, fundingParams.migrate(commitmentFormat), liquidityPurchase_opt)
}
case class ZeroconfPublishedFundingTx(tx: Transaction, localSigs_opt: Option[TxSignatures], liquidityPurchase_opt: Option[LiquidityAds.PurchaseBasicInfo]) extends LocalFundingStatus {
- override def migrate(commitmentFormat: CommitmentFormat): channel.LocalFundingStatus.ZeroconfPublishedFundingTx = channel.LocalFundingStatus.ZeroconfPublishedFundingTx(tx, localSigs_opt, liquidityPurchase_opt)
+ override def migrate(commitmentFormat: CommitmentFormat, commitInput: InputInfo): channel.LocalFundingStatus.ZeroconfPublishedFundingTx = channel.LocalFundingStatus.ZeroconfPublishedFundingTx(tx, localSigs_opt, liquidityPurchase_opt)
}
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 = {
+ override def migrate(commitmentFormat: CommitmentFormat, commitInput: InputInfo): channel.LocalFundingStatus.ConfirmedFundingTx = {
val spentInputs = tx.txIn.map(_.outPoint)
- val txOut = tx.txOut(shortChannelId.outputIndex)
- channel.LocalFundingStatus.ConfirmedFundingTx(spentInputs, txOut, shortChannelId, localSigs_opt, liquidityPurchase_opt)
+ channel.LocalFundingStatus.ConfirmedFundingTx(spentInputs, commitInput.txOut, shortChannelId, localSigs_opt, liquidityPurchase_opt)
}
}
@@ -146,7 +145,7 @@ private[channel] object ChannelTypes4 {
fundingInput = localCommit.input.outPoint,
fundingAmount = localCommit.input.txOut.amount,
remoteFundingPubKey = remoteFundingPubKey,
- localFundingStatus = localFundingStatus.migrate(params.channelFeatures.commitmentFormat),
+ localFundingStatus = localFundingStatus.migrate(params.channelFeatures.commitmentFormat, localCommit.input),
remoteFundingStatus = remoteFundingStatus,
commitmentFormat = params.channelFeatures.commitmentFormat,
localCommitParams = params.localCommitParams(),
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4Spec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4Spec.scala
index a7bb87d..5b09095 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4Spec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4Spec.scala
@@ -85,7 +85,7 @@ class ChannelCodecs4Spec extends AnyFunSuite {
test("decode unconfirmed dual funded") {
// data encoded with the previous version of eclair, when Shared.Input did not include a pubkey script
val raw = ByteVector.fromValidHex("0x020001ff02000000000000002a2400000000000000000000000000000000000000000000000000000000000000000000000000003039000000000000006400000000000000c8000000000000012c02000000000000002b04deadbeef000000000000006400000000000000c8000000000000012c00000000000000000000000042000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003e80000000000000000000000000000000000000000000000000000000000000000ff000000000000006400000000000000c8ff0001240000000000000000000000000000000000000000000000000000000000000000000000002be803000000000000220020eb72e573a9513d982a01f0e6a6b53e92764db81a0c26d2be94c5fc5b69a0db7d475221024d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d076621031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f52ae00000000024d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f000000000000000000000000014a000002ee0000")
- val decoded = fundingTxStatusCodec.decode(raw.bits).require.value.migrate(ZeroFeeHtlcTxAnchorOutputsCommitmentFormat)
+ val decoded = fundingTxStatusCodec.decode(raw.bits).require.value.migrate(ZeroFeeHtlcTxAnchorOutputsCommitmentFormat, InputInfo(OutPoint(TxId(ByteVector32.Zeroes), 0), TxOut(1000 sat, hex"deadbeef")))
// check that our codec will set the pubkeyscript using the one from the funding params
val channelId = ByteVector32.Zeroes
Why this scored 41/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.