Allow omitting `previousTx` for taproot splices (#3143)
What changed, and why it matters
This commit changes how Eclair handles taproot channel splicing. Normally, when two parties build a shared Bitcoin transaction together, each must send the full previous transaction that created the coins being spent. For taproot channels, the commit allows sending only the relevant output details instead, because taproot signatures already protect against a specific malleability trick. The change is framed as a bandwidth optimization and is restricted to taproot splicing to preserve security guarantees.
Review that the TaprootCommitmentFormat check cannot be bypassed when no shared input exists, and confirm that all code paths constructing or validating remote inputs enforce the same non-segwit and replaceability checks. Consider whether older segwit-v0 channels correctly reject PrevTxOut in mixed-feature deployments.
Security signals we found
New wire protocol TLV (PrevTxOut, type 1107) for reduced transaction disclosure
Validation gate restricts previousTxOut omission to taproot commitment formats
Explicit defense against non-segwit input substitution / malleability
Sequence-number validation now enforced for both full-previous-tx and previousTxOut paths
Test added to ensure previousTxOut is rejected for non-taproot channels
Evidence from the diff
The patch adds a new TLV field, PrevTxOut (type 1107), to TxAddInput messages. When both participants sign a taproot (segwit v1) input during a splice, the shared taproot signature commits to every spent txOut, preventing the non-segwit input substitution attack described in delvingbitcoin.org/t/malleability-issues-when-creating-shared-transactions-with-segwit-v0/497. InteractiveTxBuilder now accepts either a full previousTx or a previousTxOut for remote inputs, but only when the shared input uses TaprootCommitmentFormat. It also moves the non-replaceable-input sequence check into the same validation block. Tests cover serialization and a non-taproot rejection case.
Changed components
InteractiveTxBuilder.scalaInteractiveTxTlv.scalaLightningMessageTypes.scalaInteractiveTxBuilderSpec.scalaLightningMessageCodecsSpec.scalaInspect captured patch +72 / −25
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala
index ff52d0b..791e6d7 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala
@@ -588,30 +588,32 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon
if (session.remoteInputs.exists(_.serialId == addInput.serialId)) {
return Left(DuplicateSerialId(fundingParams.channelId, addInput.serialId))
}
- // We check whether this is the shared input or a remote input.
- val input = addInput.previousTx_opt match {
- case Some(previousTx) if previousTx.txOut.length <= addInput.previousTxOutput =>
- return Left(InputOutOfBounds(fundingParams.channelId, addInput.serialId, previousTx.txid, addInput.previousTxOutput))
- case Some(previousTx) if fundingParams.sharedInput_opt.exists(_.info.outPoint == OutPoint(previousTx, addInput.previousTxOutput.toInt)) =>
- return Left(InvalidSharedInput(fundingParams.channelId, addInput.serialId))
- case Some(previousTx) if !Script.isNativeWitnessScript(previousTx.txOut(addInput.previousTxOutput.toInt).publicKeyScript) =>
- return Left(NonSegwitInput(fundingParams.channelId, addInput.serialId, previousTx.txid, addInput.previousTxOutput))
- case Some(previousTx) =>
- Input.Remote(addInput.serialId, OutPoint(previousTx, addInput.previousTxOutput.toInt), previousTx.txOut(addInput.previousTxOutput.toInt), addInput.sequence)
- case None =>
- (addInput.sharedInput_opt, fundingParams.sharedInput_opt) match {
- case (Some(outPoint), Some(sharedInput)) if outPoint == sharedInput.info.outPoint =>
- Input.Shared(addInput.serialId, outPoint, sharedInput.info.txOut.publicKeyScript, addInput.sequence, purpose.previousLocalBalance, purpose.previousRemoteBalance, purpose.htlcBalance)
- case _ =>
- return Left(PreviousTxMissing(fundingParams.channelId, addInput.serialId))
- }
+ // We check whether this is the shared input or a remote input, and validate input details if it is not the shared input.
+ // The remote input details are usually provided by sending the entire previous transaction.
+ // But when splicing a taproot channel, it is possible to only send the previous txOut (which saves bandwidth and
+ // allows spending transactions that are larger than 65kB) because the signature of the shared taproot input will
+ // commit to *every* txOut that is being spent, which protects against malleability issues.
+ // See https://delvingbitcoin.org/t/malleability-issues-when-creating-shared-transactions-with-segwit-v0/497 for more details.
+ val remoteInputInfo_opt = (addInput.previousTx_opt, addInput.previousTxOut_opt) match {
+ case (Some(previousTx), _) if previousTx.txOut.length <= addInput.previousTxOutput => return Left(InputOutOfBounds(fundingParams.channelId, addInput.serialId, previousTx.txid, addInput.previousTxOutput))
+ case (Some(previousTx), _) => Some(InputInfo(OutPoint(previousTx, addInput.previousTxOutput.toInt), previousTx.txOut(addInput.previousTxOutput.toInt)))
+ case (None, Some(_)) if !fundingParams.sharedInput_opt.exists(_.commitmentFormat.isInstanceOf[TaprootCommitmentFormat]) => return Left(PreviousTxMissing(fundingParams.channelId, addInput.serialId))
+ case (None, Some(inputInfo)) => Some(inputInfo)
+ case (None, None) => None
+ }
+ val input = remoteInputInfo_opt match {
+ case Some(input) if !Script.isNativeWitnessScript(input.txOut.publicKeyScript) => return Left(NonSegwitInput(fundingParams.channelId, addInput.serialId, input.outPoint.txid, addInput.previousTxOutput))
+ case Some(input) if addInput.sequence > 0xfffffffdL => return Left(NonReplaceableInput(fundingParams.channelId, addInput.serialId, input.outPoint.txid, input.outPoint.index, addInput.sequence))
+ case Some(input) if fundingParams.sharedInput_opt.exists(_.info.outPoint == input.outPoint) => return Left(InvalidSharedInput(fundingParams.channelId, addInput.serialId))
+ case Some(input) => Input.Remote(addInput.serialId, input.outPoint, input.txOut, addInput.sequence)
+ case None => (addInput.sharedInput_opt, fundingParams.sharedInput_opt) match {
+ case (Some(outPoint), Some(sharedInput)) if outPoint == sharedInput.info.outPoint => Input.Shared(addInput.serialId, outPoint, sharedInput.info.txOut.publicKeyScript, addInput.sequence, purpose.previousLocalBalance, purpose.previousRemoteBalance, purpose.htlcBalance)
+ case _ => return Left(PreviousTxMissing(fundingParams.channelId, addInput.serialId))
+ }
}
if (session.localInputs.exists(_.outPoint == input.outPoint) || session.remoteInputs.exists(_.outPoint == input.outPoint)) {
return Left(DuplicateInput(fundingParams.channelId, addInput.serialId, input.outPoint.txid, input.outPoint.index))
}
- if (input.sequence > 0xfffffffdL) {
- return Left(NonReplaceableInput(fundingParams.channelId, addInput.serialId, input.outPoint.txid, input.outPoint.index, addInput.sequence))
- }
Right(input)
}
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 d13c38b..6edf10f 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
@@ -17,13 +17,14 @@
package fr.acinq.eclair.wire.protocol
import fr.acinq.bitcoin.crypto.musig2.IndividualNonce
-import fr.acinq.bitcoin.scalacompat.{ByteVector64, TxId}
+import fr.acinq.bitcoin.scalacompat.{ByteVector64, Satoshi, TxId}
import fr.acinq.eclair.UInt64
import fr.acinq.eclair.channel.ChannelSpendSignature.PartialSignatureWithNonce
import fr.acinq.eclair.wire.protocol.CommonCodecs._
import fr.acinq.eclair.wire.protocol.TlvCodecs.{tlvField, tlvStream}
import scodec.Codec
-import scodec.codecs.{bitsRemaining, discriminated, optional}
+import scodec.bits.ByteVector
+import scodec.codecs._
/**
* Created by t-bast on 08/04/2022.
@@ -35,9 +36,21 @@ object TxAddInputTlv {
/** When doing a splice, the initiator must provide the previous funding txId instead of the whole transaction. */
case class SharedInputTxId(txId: TxId) extends TxAddInputTlv
+ /**
+ * When creating an interactive-tx where both participants sign a taproot input, we don't need to provide the entire
+ * previous transaction in [[TxAddInput]]: signatures will commit to the txOut of *all* of the transaction's inputs,
+ * which ensures that nodes cannot cheat and downgrade to a non-segwit input.
+ */
+ case class PrevTxOut(txId: TxId, amount: Satoshi, publicKeyScript: ByteVector) extends TxAddInputTlv
+
+ object PrevTxOut {
+ val codec: Codec[PrevTxOut] = tlvField((txIdAsHash :: satoshi :: bytes).as[PrevTxOut])
+ }
+
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)
)
}
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala
index 74129f5..6573602 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala
@@ -20,11 +20,12 @@ import com.google.common.base.Charsets
import com.google.common.net.InetAddresses
import fr.acinq.bitcoin.crypto.musig2.IndividualNonce
import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey}
-import fr.acinq.bitcoin.scalacompat.{BlockHash, ByteVector32, ByteVector64, OutPoint, Satoshi, SatoshiLong, ScriptWitness, Transaction, TxId}
+import fr.acinq.bitcoin.scalacompat.{BlockHash, ByteVector32, ByteVector64, OutPoint, Satoshi, SatoshiLong, ScriptWitness, Transaction, TxId, TxOut}
import fr.acinq.eclair.blockchain.fee.FeeratePerKw
import fr.acinq.eclair.channel.ChannelSpendSignature.{IndividualSignature, PartialSignatureWithNonce}
import fr.acinq.eclair.channel.{ChannelFlags, ChannelSpendSignature, ChannelType}
import fr.acinq.eclair.payment.relay.Relayer
+import fr.acinq.eclair.transactions.Transactions.InputInfo
import fr.acinq.eclair.wire.protocol.ChannelReadyTlv.ShortChannelIdTlv
import fr.acinq.eclair.{Alias, BlockHeight, CltvExpiry, CltvExpiryDelta, Feature, Features, InitFeature, MilliSatoshi, MilliSatoshiLong, RealShortChannelId, ShortChannelId, TimestampSecond, UInt64, isAsciiPrintable}
import scodec.bits.ByteVector
@@ -94,6 +95,8 @@ case class TxAddInput(channelId: ByteVector32,
previousTxOutput: Long,
sequence: Long,
tlvStream: TlvStream[TxAddInputTlv] = TlvStream.empty) extends InteractiveTxConstructionMessage with HasChannelId with HasSerialId {
+ /** This field may replace [[previousTx_opt]] when using taproot. */
+ val previousTxOut_opt: Option[InputInfo] = tlvStream.get[TxAddInputTlv.PrevTxOut].map(tlv => InputInfo(OutPoint(tlv.txId, previousTxOutput), TxOut(tlv.amount, tlv.publicKeyScript)))
val sharedInput_opt: Option[OutPoint] = tlvStream.get[TxAddInputTlv.SharedInputTxId].map(i => OutPoint(i.txId, previousTxOutput))
}
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 82ffd78..32e81a0 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
@@ -1981,6 +1981,15 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit
}
}
+ private def replacePrevTxWithPrevTxOut(input: TxAddInput): TxAddInput = {
+ input.previousTx_opt match {
+ case None => input
+ case Some(tx) =>
+ val txOut = tx.txOut(input.previousTxOutput.toInt)
+ input.copy(previousTx_opt = None, tlvStream = TlvStream(TxAddInputTlv.PrevTxOut(tx.txid, txOut.amount, txOut.publicKeyScript)))
+ }
+ }
+
test("fund splice transaction with previous inputs (different balance)") {
val targetFeerate = FeeratePerKw(2_500 sat)
val fundingA1 = 100_000 sat
@@ -2029,12 +2038,15 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit
aliceSplice ! Start(alice2bob.ref)
bobSplice ! Start(bob2alice.ref)
+ // Since we're splicing a taproot channel, we can replace the entire previous transaction by only its txOut.
// Alice --- tx_add_input --> Bob
- fwdSplice.forwardAlice2Bob[TxAddInput]
+ val input1 = alice2bob.expectMsgType[SendMessage].msg.asInstanceOf[TxAddInput]
+ bobSplice ! ReceiveMessage(replacePrevTxWithPrevTxOut(input1))
// Alice <-- tx_complete --- Bob
fwdSplice.forwardBob2Alice[TxComplete]
// Alice --- tx_add_input --> Bob
- fwdSplice.forwardAlice2Bob[TxAddInput]
+ val input2 = alice2bob.expectMsgType[SendMessage].msg.asInstanceOf[TxAddInput]
+ bobSplice ! ReceiveMessage(replacePrevTxWithPrevTxOut(input2))
// Alice <-- tx_complete --- Bob
fwdSplice.forwardBob2Alice[TxComplete]
// Alice --- tx_add_output --> Bob
@@ -2518,6 +2530,8 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit
TxAddInput(params.channelId, UInt64(7), Some(previousTx), 1, 0) -> NonSegwitInput(params.channelId, UInt64(7), previousTx.txid, 1),
TxAddInput(params.channelId, UInt64(9), Some(previousTx), 2, 0xfffffffeL) -> NonReplaceableInput(params.channelId, UInt64(9), previousTx.txid, 2, 0xfffffffeL),
TxAddInput(params.channelId, UInt64(9), Some(previousTx), 2, 0xffffffffL) -> NonReplaceableInput(params.channelId, UInt64(9), previousTx.txid, 2, 0xffffffffL),
+ // Replacing the previousTx field with previousTxOut is only allowed for splices on taproot channels.
+ TxAddInput(params.channelId, UInt64(5), None, 0, 0, TlvStream(TxAddInputTlv.PrevTxOut(previousTx.txid, previousOutputs(0).amount, previousOutputs(0).publicKeyScript))) -> PreviousTxMissing(params.channelId, UInt64(5))
)
testCases.foreach {
case (input, expected) =>
@@ -2751,6 +2765,20 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit
assert(probe.expectMsgType[RemoteFailure].cause == PreviousTxMissing(params.channelId, UInt64(0)))
}
+ test("previous txOut not allowed for non-taproot channels") {
+ val probe = TestProbe()
+ val wallet = new SingleKeyOnChainWallet()
+ val params = createFixtureParams(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), 100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0)
+ val previousCommitment = CommitmentsSpec.makeCommitments(25_000_000 msat, 50_000_000 msat).active.head
+ val fundingParams = params.fundingParamsB.copy(sharedInput_opt = Some(SharedFundingInput(previousCommitment.commitInput(params.channelKeysB), 0, randomKey().publicKey, previousCommitment.commitmentFormat)))
+ val bob = params.spawnTxBuilderSpliceBob(fundingParams, previousCommitment, wallet)
+ bob ! Start(probe.ref)
+ // Alice --- tx_add_input --> Bob
+ // The input only includes the previous txOut which is only allowed for taproot channels.
+ bob ! ReceiveMessage(TxAddInput(params.channelId, UInt64(0), None, 0, 0, TlvStream(TxAddInputTlv.PrevTxOut(randomTxId(), 100_000 sat, Script.write(Script.pay2tr(randomKey().xOnlyPublicKey()))))))
+ assert(probe.expectMsgType[RemoteFailure].cause == PreviousTxMissing(params.channelId, UInt64(0)))
+ }
+
test("invalid shared input") {
val probe = TestProbe()
val wallet = new SingleKeyOnChainWallet()
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 6770433..64e9258 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,6 +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",
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 28/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.