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 ff52d0b9d2..791e6d7028 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 d13c38b9b9..6edf10f5e5 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 74129f5312..65736023b0 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 82ffd78545..32e81a0eb2 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 6770433159..64e9258707 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",