Skip to content

Commit 0e0da42

Browse files
authored
Allow omitting previousTx for taproot splices (#3143)
When splicing a taproot channel, both participants will provide a signature for a segwit v1 input: this signature will cover every spent `txOut`, including their amount and script. This ensures that attackers cannot reuse a signature while replacing a segwit input with a non-segwit input, which could be used to steal funds. A side-effect of this change in signature behavior is that we don't need to provide the entire previous transaction when both channel participants sign a taproot input. For simplicity, we only allow this simplification when splicing taproot channels for now. We can also allow channel creation based on swap-in-potentiam, which also uses musig2 and has the same non-malleability guarantee (on feature branches for phoenix users).
1 parent d8ce91b commit 0e0da42

File tree

5 files changed

+72
-25
lines changed

5 files changed

+72
-25
lines changed

eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -588,30 +588,32 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon
588588
if (session.remoteInputs.exists(_.serialId == addInput.serialId)) {
589589
return Left(DuplicateSerialId(fundingParams.channelId, addInput.serialId))
590590
}
591-
// We check whether this is the shared input or a remote input.
592-
val input = addInput.previousTx_opt match {
593-
case Some(previousTx) if previousTx.txOut.length <= addInput.previousTxOutput =>
594-
return Left(InputOutOfBounds(fundingParams.channelId, addInput.serialId, previousTx.txid, addInput.previousTxOutput))
595-
case Some(previousTx) if fundingParams.sharedInput_opt.exists(_.info.outPoint == OutPoint(previousTx, addInput.previousTxOutput.toInt)) =>
596-
return Left(InvalidSharedInput(fundingParams.channelId, addInput.serialId))
597-
case Some(previousTx) if !Script.isNativeWitnessScript(previousTx.txOut(addInput.previousTxOutput.toInt).publicKeyScript) =>
598-
return Left(NonSegwitInput(fundingParams.channelId, addInput.serialId, previousTx.txid, addInput.previousTxOutput))
599-
case Some(previousTx) =>
600-
Input.Remote(addInput.serialId, OutPoint(previousTx, addInput.previousTxOutput.toInt), previousTx.txOut(addInput.previousTxOutput.toInt), addInput.sequence)
601-
case None =>
602-
(addInput.sharedInput_opt, fundingParams.sharedInput_opt) match {
603-
case (Some(outPoint), Some(sharedInput)) if outPoint == sharedInput.info.outPoint =>
604-
Input.Shared(addInput.serialId, outPoint, sharedInput.info.txOut.publicKeyScript, addInput.sequence, purpose.previousLocalBalance, purpose.previousRemoteBalance, purpose.htlcBalance)
605-
case _ =>
606-
return Left(PreviousTxMissing(fundingParams.channelId, addInput.serialId))
607-
}
591+
// We check whether this is the shared input or a remote input, and validate input details if it is not the shared input.
592+
// The remote input details are usually provided by sending the entire previous transaction.
593+
// But when splicing a taproot channel, it is possible to only send the previous txOut (which saves bandwidth and
594+
// allows spending transactions that are larger than 65kB) because the signature of the shared taproot input will
595+
// commit to *every* txOut that is being spent, which protects against malleability issues.
596+
// See https://delvingbitcoin.org/t/malleability-issues-when-creating-shared-transactions-with-segwit-v0/497 for more details.
597+
val remoteInputInfo_opt = (addInput.previousTx_opt, addInput.previousTxOut_opt) match {
598+
case (Some(previousTx), _) if previousTx.txOut.length <= addInput.previousTxOutput => return Left(InputOutOfBounds(fundingParams.channelId, addInput.serialId, previousTx.txid, addInput.previousTxOutput))
599+
case (Some(previousTx), _) => Some(InputInfo(OutPoint(previousTx, addInput.previousTxOutput.toInt), previousTx.txOut(addInput.previousTxOutput.toInt)))
600+
case (None, Some(_)) if !fundingParams.sharedInput_opt.exists(_.commitmentFormat.isInstanceOf[TaprootCommitmentFormat]) => return Left(PreviousTxMissing(fundingParams.channelId, addInput.serialId))
601+
case (None, Some(inputInfo)) => Some(inputInfo)
602+
case (None, None) => None
603+
}
604+
val input = remoteInputInfo_opt match {
605+
case Some(input) if !Script.isNativeWitnessScript(input.txOut.publicKeyScript) => return Left(NonSegwitInput(fundingParams.channelId, addInput.serialId, input.outPoint.txid, addInput.previousTxOutput))
606+
case Some(input) if addInput.sequence > 0xfffffffdL => return Left(NonReplaceableInput(fundingParams.channelId, addInput.serialId, input.outPoint.txid, input.outPoint.index, addInput.sequence))
607+
case Some(input) if fundingParams.sharedInput_opt.exists(_.info.outPoint == input.outPoint) => return Left(InvalidSharedInput(fundingParams.channelId, addInput.serialId))
608+
case Some(input) => Input.Remote(addInput.serialId, input.outPoint, input.txOut, addInput.sequence)
609+
case None => (addInput.sharedInput_opt, fundingParams.sharedInput_opt) match {
610+
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)
611+
case _ => return Left(PreviousTxMissing(fundingParams.channelId, addInput.serialId))
612+
}
608613
}
609614
if (session.localInputs.exists(_.outPoint == input.outPoint) || session.remoteInputs.exists(_.outPoint == input.outPoint)) {
610615
return Left(DuplicateInput(fundingParams.channelId, addInput.serialId, input.outPoint.txid, input.outPoint.index))
611616
}
612-
if (input.sequence > 0xfffffffdL) {
613-
return Left(NonReplaceableInput(fundingParams.channelId, addInput.serialId, input.outPoint.txid, input.outPoint.index, addInput.sequence))
614-
}
615617
Right(input)
616618
}
617619

eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/InteractiveTxTlv.scala

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,14 @@
1717
package fr.acinq.eclair.wire.protocol
1818

1919
import fr.acinq.bitcoin.crypto.musig2.IndividualNonce
20-
import fr.acinq.bitcoin.scalacompat.{ByteVector64, TxId}
20+
import fr.acinq.bitcoin.scalacompat.{ByteVector64, Satoshi, TxId}
2121
import fr.acinq.eclair.UInt64
2222
import fr.acinq.eclair.channel.ChannelSpendSignature.PartialSignatureWithNonce
2323
import fr.acinq.eclair.wire.protocol.CommonCodecs._
2424
import fr.acinq.eclair.wire.protocol.TlvCodecs.{tlvField, tlvStream}
2525
import scodec.Codec
26-
import scodec.codecs.{bitsRemaining, discriminated, optional}
26+
import scodec.bits.ByteVector
27+
import scodec.codecs._
2728

2829
/**
2930
* Created by t-bast on 08/04/2022.
@@ -35,9 +36,21 @@ object TxAddInputTlv {
3536
/** When doing a splice, the initiator must provide the previous funding txId instead of the whole transaction. */
3637
case class SharedInputTxId(txId: TxId) extends TxAddInputTlv
3738

39+
/**
40+
* When creating an interactive-tx where both participants sign a taproot input, we don't need to provide the entire
41+
* previous transaction in [[TxAddInput]]: signatures will commit to the txOut of *all* of the transaction's inputs,
42+
* which ensures that nodes cannot cheat and downgrade to a non-segwit input.
43+
*/
44+
case class PrevTxOut(txId: TxId, amount: Satoshi, publicKeyScript: ByteVector) extends TxAddInputTlv
45+
46+
object PrevTxOut {
47+
val codec: Codec[PrevTxOut] = tlvField((txIdAsHash :: satoshi :: bytes).as[PrevTxOut])
48+
}
49+
3850
val txAddInputTlvCodec: Codec[TlvStream[TxAddInputTlv]] = tlvStream(discriminated[TxAddInputTlv].by(varint)
3951
// Note that we actually encode as a tx_hash to be consistent with other lightning messages.
4052
.typecase(UInt64(1105), tlvField(txIdAsHash.as[SharedInputTxId]))
53+
.typecase(UInt64(1107), PrevTxOut.codec)
4154
)
4255
}
4356

eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,12 @@ import com.google.common.base.Charsets
2020
import com.google.common.net.InetAddresses
2121
import fr.acinq.bitcoin.crypto.musig2.IndividualNonce
2222
import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey}
23-
import fr.acinq.bitcoin.scalacompat.{BlockHash, ByteVector32, ByteVector64, OutPoint, Satoshi, SatoshiLong, ScriptWitness, Transaction, TxId}
23+
import fr.acinq.bitcoin.scalacompat.{BlockHash, ByteVector32, ByteVector64, OutPoint, Satoshi, SatoshiLong, ScriptWitness, Transaction, TxId, TxOut}
2424
import fr.acinq.eclair.blockchain.fee.FeeratePerKw
2525
import fr.acinq.eclair.channel.ChannelSpendSignature.{IndividualSignature, PartialSignatureWithNonce}
2626
import fr.acinq.eclair.channel.{ChannelFlags, ChannelSpendSignature, ChannelType}
2727
import fr.acinq.eclair.payment.relay.Relayer
28+
import fr.acinq.eclair.transactions.Transactions.InputInfo
2829
import fr.acinq.eclair.wire.protocol.ChannelReadyTlv.ShortChannelIdTlv
2930
import fr.acinq.eclair.{Alias, BlockHeight, CltvExpiry, CltvExpiryDelta, Feature, Features, InitFeature, MilliSatoshi, MilliSatoshiLong, RealShortChannelId, ShortChannelId, TimestampSecond, UInt64, isAsciiPrintable}
3031
import scodec.bits.ByteVector
@@ -94,6 +95,8 @@ case class TxAddInput(channelId: ByteVector32,
9495
previousTxOutput: Long,
9596
sequence: Long,
9697
tlvStream: TlvStream[TxAddInputTlv] = TlvStream.empty) extends InteractiveTxConstructionMessage with HasChannelId with HasSerialId {
98+
/** This field may replace [[previousTx_opt]] when using taproot. */
99+
val previousTxOut_opt: Option[InputInfo] = tlvStream.get[TxAddInputTlv.PrevTxOut].map(tlv => InputInfo(OutPoint(tlv.txId, previousTxOutput), TxOut(tlv.amount, tlv.publicKeyScript)))
97100
val sharedInput_opt: Option[OutPoint] = tlvStream.get[TxAddInputTlv.SharedInputTxId].map(i => OutPoint(i.txId, previousTxOutput))
98101
}
99102

eclair-core/src/test/scala/fr/acinq/eclair/channel/InteractiveTxBuilderSpec.scala

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1981,6 +1981,15 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit
19811981
}
19821982
}
19831983

1984+
private def replacePrevTxWithPrevTxOut(input: TxAddInput): TxAddInput = {
1985+
input.previousTx_opt match {
1986+
case None => input
1987+
case Some(tx) =>
1988+
val txOut = tx.txOut(input.previousTxOutput.toInt)
1989+
input.copy(previousTx_opt = None, tlvStream = TlvStream(TxAddInputTlv.PrevTxOut(tx.txid, txOut.amount, txOut.publicKeyScript)))
1990+
}
1991+
}
1992+
19841993
test("fund splice transaction with previous inputs (different balance)") {
19851994
val targetFeerate = FeeratePerKw(2_500 sat)
19861995
val fundingA1 = 100_000 sat
@@ -2029,12 +2038,15 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit
20292038
aliceSplice ! Start(alice2bob.ref)
20302039
bobSplice ! Start(bob2alice.ref)
20312040

2041+
// Since we're splicing a taproot channel, we can replace the entire previous transaction by only its txOut.
20322042
// Alice --- tx_add_input --> Bob
2033-
fwdSplice.forwardAlice2Bob[TxAddInput]
2043+
val input1 = alice2bob.expectMsgType[SendMessage].msg.asInstanceOf[TxAddInput]
2044+
bobSplice ! ReceiveMessage(replacePrevTxWithPrevTxOut(input1))
20342045
// Alice <-- tx_complete --- Bob
20352046
fwdSplice.forwardBob2Alice[TxComplete]
20362047
// Alice --- tx_add_input --> Bob
2037-
fwdSplice.forwardAlice2Bob[TxAddInput]
2048+
val input2 = alice2bob.expectMsgType[SendMessage].msg.asInstanceOf[TxAddInput]
2049+
bobSplice ! ReceiveMessage(replacePrevTxWithPrevTxOut(input2))
20382050
// Alice <-- tx_complete --- Bob
20392051
fwdSplice.forwardBob2Alice[TxComplete]
20402052
// Alice --- tx_add_output --> Bob
@@ -2518,6 +2530,8 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit
25182530
TxAddInput(params.channelId, UInt64(7), Some(previousTx), 1, 0) -> NonSegwitInput(params.channelId, UInt64(7), previousTx.txid, 1),
25192531
TxAddInput(params.channelId, UInt64(9), Some(previousTx), 2, 0xfffffffeL) -> NonReplaceableInput(params.channelId, UInt64(9), previousTx.txid, 2, 0xfffffffeL),
25202532
TxAddInput(params.channelId, UInt64(9), Some(previousTx), 2, 0xffffffffL) -> NonReplaceableInput(params.channelId, UInt64(9), previousTx.txid, 2, 0xffffffffL),
2533+
// Replacing the previousTx field with previousTxOut is only allowed for splices on taproot channels.
2534+
TxAddInput(params.channelId, UInt64(5), None, 0, 0, TlvStream(TxAddInputTlv.PrevTxOut(previousTx.txid, previousOutputs(0).amount, previousOutputs(0).publicKeyScript))) -> PreviousTxMissing(params.channelId, UInt64(5))
25212535
)
25222536
testCases.foreach {
25232537
case (input, expected) =>
@@ -2751,6 +2765,20 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit
27512765
assert(probe.expectMsgType[RemoteFailure].cause == PreviousTxMissing(params.channelId, UInt64(0)))
27522766
}
27532767

2768+
test("previous txOut not allowed for non-taproot channels") {
2769+
val probe = TestProbe()
2770+
val wallet = new SingleKeyOnChainWallet()
2771+
val params = createFixtureParams(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), 100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0)
2772+
val previousCommitment = CommitmentsSpec.makeCommitments(25_000_000 msat, 50_000_000 msat).active.head
2773+
val fundingParams = params.fundingParamsB.copy(sharedInput_opt = Some(SharedFundingInput(previousCommitment.commitInput(params.channelKeysB), 0, randomKey().publicKey, previousCommitment.commitmentFormat)))
2774+
val bob = params.spawnTxBuilderSpliceBob(fundingParams, previousCommitment, wallet)
2775+
bob ! Start(probe.ref)
2776+
// Alice --- tx_add_input --> Bob
2777+
// The input only includes the previous txOut which is only allowed for taproot channels.
2778+
bob ! ReceiveMessage(TxAddInput(params.channelId, UInt64(0), None, 0, 0, TlvStream(TxAddInputTlv.PrevTxOut(randomTxId(), 100_000 sat, Script.write(Script.pay2tr(randomKey().xOnlyPublicKey()))))))
2779+
assert(probe.expectMsgType[RemoteFailure].cause == PreviousTxMissing(params.channelId, UInt64(0)))
2780+
}
2781+
27542782
test("invalid shared input") {
27552783
val probe = TestProbe()
27562784
val wallet = new SingleKeyOnChainWallet()

eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ class LightningMessageCodecsSpec extends AnyFunSuite {
223223
TxAddInput(channelId2, UInt64(0), Some(tx2), 2, 0) -> hex"0042 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 0000000000000000 0100 0200000000010142180a8812fc79a3da7fb2471eff3e22d7faee990604c2ba7f2fc8dfb15b550a0200000000feffffff030f241800000000001976a9146774040642a78ca3b8b395e70f8391b21ec026fc88ac4a155801000000001600148d2e0b57adcb8869e603fd35b5179caf053361253b1d010000000000160014e032f4f4b9f8611df0d30a20648c190c263bbc33024730440220506005aa347f5b698542cafcb4f1a10250aeb52a609d6fd67ef68f9c1a5d954302206b9bb844343f4012bccd9d08a0f5430afb9549555a3252e499be7df97aae477a012103976d6b3eea3de4b056cd88cdfd50a22daf121e0fb5c6e45ba0f40e1effbd275a00000000 00000002 00000000",
224224
TxAddInput(channelId1, UInt64(561), Some(tx1), 0, 0) -> hex"0042 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000231 00f7 020000000001014ade359c5deb7c1cde2e94f401854658f97d7fa31c17ce9a831db253120a0a410100000017160014eb9a5bd79194a23d19d6ec473c768fb74f9ed32cffffffff021ca408000000000017a914946118f24bb7b37d5e9e39579e4a411e70f5b6a08763e703000000000017a9143638b2602d11f934c04abc6adb1494f69d1f14af8702473044022059ddd943b399211e4266a349f26b3289979e29f9b067792c6cfa8cc5ae25f44602204d627a5a5b603d0562e7969011fb3d64908af90a3ec7c876eaa9baf61e1958af012102f5188df1da92ed818581c29778047800ed6635788aa09d9469f7d17628f7323300000000 00000000 00000000",
225225
TxAddInput(channelId1, UInt64(561), OutPoint(tx1, 1), 5) -> hex"0042 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000231 0000 00000001 00000005 fd0451201f2ec025a33e39ef8e177afcdc1adc855bf128dc906182255aeb64efa825f106",
226+
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",
226227
TxAddOutput(channelId1, UInt64(1105), 2047 sat, hex"00149357014afd0ccd265658c9ae81efa995e771f472") -> hex"0043 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000451 00000000000007ff 0016 00149357014afd0ccd265658c9ae81efa995e771f472",
227228
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",
228229
TxRemoveInput(channelId2, UInt64(561)) -> hex"0044 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 0000000000000231",

0 commit comments

Comments
 (0)