From 2c29ecb96c5fe9b95584d0852323d66e7f117269 Mon Sep 17 00:00:00 2001 From: t-bast Date: Thu, 31 Jul 2025 13:56:49 +0200 Subject: [PATCH 01/18] Add more parameters to nonce generation We provide both funding keys to the nonce generation algorithm, which better matches the interface provided by libsecp256k1. We also provide the `funding_txid` to the signing nonce generation to improve the extra randomness used. --- .../fr/acinq/eclair/channel/Commitments.scala | 46 +++++++-------- .../fr/acinq/eclair/channel/Helpers.scala | 14 ++--- .../fr/acinq/eclair/channel/fsm/Channel.scala | 21 +++---- .../channel/fsm/ChannelOpenSingleFunded.scala | 56 ++++++++---------- .../channel/fsm/CommonFundingHandlers.scala | 3 +- .../channel/fund/InteractiveTxBuilder.scala | 8 +-- .../acinq/eclair/crypto/NonceGenerator.scala | 29 +++++---- .../eclair/crypto/NonceGeneratorSpec.scala | 59 +++++++++++++++++++ 8 files changed, 144 insertions(+), 92 deletions(-) create mode 100644 eclair-core/src/test/scala/fr/acinq/eclair/crypto/NonceGeneratorSpec.scala diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala index 46a5298139..37e2a45895 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala @@ -3,8 +3,7 @@ package fr.acinq.eclair.channel import akka.event.LoggingAdapter import fr.acinq.bitcoin.crypto.musig2.IndividualNonce import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} -import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, Crypto, OutPoint, Satoshi, SatoshiLong, Transaction, TxId} -import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, Crypto, Musig2, Satoshi, SatoshiLong, Script, Transaction, TxId} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, Crypto, Musig2, OutPoint, Satoshi, SatoshiLong, Script, Transaction, TxId} import fr.acinq.eclair.blockchain.fee.{FeeratePerByte, FeeratePerKw, FeeratesPerKw, OnChainFeeConf} import fr.acinq.eclair.channel.Helpers.Closing import fr.acinq.eclair.channel.Monitoring.{Metrics, Tags} @@ -175,7 +174,7 @@ object LocalCommit { case ChannelSpendSignature.IndividualSignature(sig) if !commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => localCommitTx.checkRemoteSig(fundingKey.publicKey, remoteFundingPubKey, ChannelSpendSignature.IndividualSignature(sig)) case psig: ChannelSpendSignature.PartialSignatureWithNonce if commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => - val localNonce = NonceGenerator.verificationNonce(localCommitTx.input.outPoint.txid, fundingKey, localCommitIndex) + val localNonce = NonceGenerator.verificationNonce(fundingTxId, fundingKey, remoteFundingPubKey, localCommitIndex) localCommitTx.checkRemotePartialSignature(fundingKey.publicKey, remoteFundingPubKey, psig, localNonce.publicNonce) case _ => false } @@ -212,9 +211,9 @@ case class RemoteCommit(index: Long, spec: CommitmentSpec, txId: TxId, remotePer case _: SimpleTaprootChannelCommitmentFormat if remoteNonce_opt.isEmpty => Left(MissingNonce(channelParams.channelId, commitInput.outPoint.txid)) case _: SimpleTaprootChannelCommitmentFormat => - val localNonce = NonceGenerator.signingNonce(fundingKey.publicKey) + val localNonce = NonceGenerator.signingNonce(fundingKey.publicKey, remoteFundingPubKey, commitInput.outPoint.txid) remoteCommitTx.partialSign(fundingKey, remoteFundingPubKey, Map.empty, localNonce, Seq(localNonce.publicNonce, remoteNonce_opt.get)) match { - case Left(t) => Left(InvalidNonce(channelParams.channelId, commitInput.outPoint.txid)) + case Left(_) => Left(InvalidNonce(channelParams.channelId, commitInput.outPoint.txid)) case Right(psig) => Right(CommitSig(channelParams.channelId, ByteVector64.Zeroes, htlcSigs.toList, TlvStream[CommitSigTlv](CommitSigTlv.PartialSignatureWithNonceTlv(psig)))) } } @@ -666,12 +665,12 @@ case class Commitment(fundingTxIndex: Long, Metrics.recordHtlcsInFlight(spec, remoteCommit.spec) val partialSig: Option[CommitSigTlv] = commitmentFormat match { case _: SimpleTaprootChannelCommitmentFormat => - val localNonce = NonceGenerator.signingNonce(fundingKey.publicKey) + val localNonce = NonceGenerator.signingNonce(fundingKey.publicKey, remoteFundingPubKey, fundingTxId) if (nextRemoteNonce_opt.isEmpty) - return Left(MissingNonce(params.channelId, remoteCommitTx.input.outPoint.txid)) + return Left(MissingNonce(params.channelId, fundingTxId)) val Some(remoteNonce) = nextRemoteNonce_opt val psig = remoteCommitTx.partialSign(fundingKey, remoteFundingPubKey, Map.empty, localNonce, Seq(localNonce.publicNonce, remoteNonce)) match { - case Left(t) => return Left(InvalidNonce(params.channelId, remoteCommitTx.input.outPoint.txid)) + case Left(_) => return Left(InvalidNonce(params.channelId, fundingTxId)) case Right(psig) => psig } log.debug(s"sendCommit: creating partial sig $psig for remote commit tx ${remoteCommitTx.tx.txid} with fundingTxIndex = $fundingTxIndex remoteCommit.index (should add +1) = ${remoteCommit.index} remote nonce $remoteNonce and remoteNextPerCommitmentPoint = $remoteNextPerCommitmentPoint") @@ -716,31 +715,32 @@ case class Commitment(fundingTxIndex: Long, val commitKeys = localKeys(params, channelKeys) val (unsignedCommitTx, _) = Commitment.makeLocalTxs(params, localCommitParams, commitKeys, localCommit.index, fundingKey, remoteFundingPubKey, commitInput(fundingKey), commitmentFormat, localCommit.spec) localCommit.remoteSig match { - case remoteSig: ChannelSpendSignature.IndividualSignature if !this.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => + case remoteSig: ChannelSpendSignature.IndividualSignature if !commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => val localSig = unsignedCommitTx.sign(fundingKey, remoteFundingPubKey) Right(unsignedCommitTx.aggregateSigs(fundingKey.publicKey, remoteFundingPubKey, localSig, remoteSig)) - case ChannelSpendSignature.PartialSignatureWithNonce(remotePsig, remoteNonce) if this.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => - val fundingTxId = if (this.fundingTxIndex == 0 && localCommit.index == 0 && !params.channelFeatures.hasFeature(Features.DualFunding)) { - TxId(ByteVector32.Zeroes) // special case because for channel establishment v1 we exchange the first nonce before the funding tx id is known + case ChannelSpendSignature.PartialSignatureWithNonce(remotePsig, remoteNonce) if commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => + val localNonce = if (fundingTxIndex == 0 && localCommit.index == 0 && !params.channelFeatures.hasFeature(Features.DualFunding)) { + // With channel establishment v1, we exchange the first nonce before the funding tx and remote funding key are known. + NonceGenerator.verificationNonce(NonceGenerator.dummyFundingTxId, fundingKey, NonceGenerator.dummyRemoteFundingPubKey, localCommit.index) } else { - unsignedCommitTx.input.outPoint.txid + NonceGenerator.verificationNonce(fundingTxId, fundingKey, remoteFundingPubKey, localCommit.index) } - val localNonce = NonceGenerator.verificationNonce(fundingTxId, fundingKey, localCommit.index) (for { partialSig <- unsignedCommitTx.partialSign(fundingKey, remoteFundingPubKey, Map.empty, localNonce, Seq(localNonce.publicNonce, remoteNonce)) - inputIndex = unsignedCommitTx.tx.txIn.indexWhere(_.outPoint == unsignedCommitTx.input.outPoint) aggSig <- Musig2.aggregateTaprootSignatures( Seq(partialSig.partialSig, remotePsig), - unsignedCommitTx.tx, inputIndex, Seq(unsignedCommitTx.input.txOut), + unsignedCommitTx.tx, + inputIndex = 0, // commit txs always have a single input + Seq(unsignedCommitTx.input.txOut), Scripts.sort(Seq(fundingKey.publicKey, remoteFundingPubKey)), Seq(localNonce.publicNonce, remoteNonce), None) signedCommitTx = unsignedCommitTx.copy(tx = unsignedCommitTx.tx.updateWitness(0, Script.witnessKeyPathPay2tr(aggSig))) } yield signedCommitTx.tx) match { - case Left(t) => Left(InvalidCommitmentSignature(params.channelId, unsignedCommitTx.input.outPoint.txid, localCommit.index, unsignedCommitTx.tx)) + case Left(_) => Left(InvalidCommitmentSignature(params.channelId, fundingTxId, localCommit.index, unsignedCommitTx.tx)) case Right(tx) => Right(tx) } - case _ => Left(InvalidCommitmentSignature(params.channelId, unsignedCommitTx.input.outPoint.txid, localCommit.index, unsignedCommitTx.tx)) + case _ => Left(InvalidCommitmentSignature(params.channelId, fundingTxId, localCommit.index, unsignedCommitTx.tx)) } } @@ -1151,15 +1151,15 @@ case class Commitments(channelParams: ChannelParams, // we will send our revocation preimage + our next revocation hash val localPerCommitmentSecret = channelKeys.commitmentSecret(localCommitIndex) val localNextPerCommitmentPoint = channelKeys.commitmentPoint(localCommitIndex + 2) - val nonces = this.active.collect { + val nonces = active.collect { case c: Commitment if c.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => val fundingKey = channelKeys.fundingKey(c.fundingTxIndex) - val n = NonceGenerator.verificationNonce(c.fundingTxId, fundingKey, localCommitIndex + 2).publicNonce + val n = NonceGenerator.verificationNonce(c.fundingTxId, fundingKey, c.remoteFundingPubKey, localCommitIndex + 2).publicNonce log.debug(s"revokeandack: creating verification nonce $n fundingIndex = ${c.fundingTxIndex} commit index = ${localCommitIndex + 2}") c.fundingTxId -> n } val nextLocalNonce = if (latest.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat]) - Some(RevokeAndAckTlv.NextLocalNonceTlv(NonceGenerator.verificationNonce(latest.fundingTxId, channelKeys.fundingKey(latest.fundingTxIndex), localCommitIndex + 2).publicNonce)) + Some(RevokeAndAckTlv.NextLocalNonceTlv(NonceGenerator.verificationNonce(latest.fundingTxId, channelKeys.fundingKey(latest.fundingTxIndex), latest.remoteFundingPubKey, localCommitIndex + 2).publicNonce)) else None val tlvStream: TlvStream[RevokeAndAckTlv] = TlvStream( @@ -1187,8 +1187,8 @@ case class Commitments(channelParams: ChannelParams, remoteNextCommitInfo match { case Right(_) => Left(UnexpectedRevocation(channelId)) case Left(_) if revocation.perCommitmentSecret.publicKey != active.head.remoteCommit.remotePerCommitmentPoint => Left(InvalidRevocation(channelId)) - case Left(_) if revocation.nexLocalNonce_opt.isEmpty && this.active.exists(c => c.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] && !revocation.nexLocalNonces.contains(c.fundingTxId)) => - Left(MissingNonce(channelId, this.latest.fundingTxId)) + case Left(_) if revocation.nexLocalNonce_opt.isEmpty && active.exists(c => c.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] && !revocation.nexLocalNonces.contains(c.fundingTxId)) => + Left(MissingNonce(channelId, latest.fundingTxId)) case Left(_) => // Since htlcs are shared across all commitments, we generate the actions only once based on the first commitment. val receivedHtlcs = changes.remoteChanges.signed.collect { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala index fbcd54efef..550dacdf09 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala @@ -17,7 +17,6 @@ package fr.acinq.eclair.channel import akka.event.{DiagnosticLoggingAdapter, LoggingAdapter} -import fr.acinq.bitcoin.ScriptFlags import fr.acinq.bitcoin.crypto.musig2.IndividualNonce import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey, sha256} import fr.acinq.bitcoin.scalacompat._ @@ -26,15 +25,14 @@ import fr.acinq.eclair.blockchain.OnChainPubkeyCache import fr.acinq.eclair.blockchain.fee._ import fr.acinq.eclair.channel.fsm.Channel import fr.acinq.eclair.channel.fsm.Channel.REFRESH_CHANNEL_UPDATE_INTERVAL -import fr.acinq.eclair.crypto.{NonceGenerator, ShaChain} import fr.acinq.eclair.crypto.keymanager.{ChannelKeys, LocalCommitmentKeys, RemoteCommitmentKeys} +import fr.acinq.eclair.crypto.{NonceGenerator, ShaChain} import fr.acinq.eclair.db.ChannelsDb import fr.acinq.eclair.payment.relay.Relayer.RelayFees import fr.acinq.eclair.router.Announcements import fr.acinq.eclair.transactions.DirectedHtlc._ import fr.acinq.eclair.transactions.Transactions._ import fr.acinq.eclair.transactions._ -import fr.acinq.eclair.wire.protocol.ChannelTlv.NextLocalNonceTlv import fr.acinq.eclair.wire.protocol._ import scodec.bits.ByteVector @@ -554,7 +552,7 @@ object Helpers { val nonces = commitments.active.collect { case c: Commitment if c.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => val fundingKey = channelKeys.fundingKey(c.fundingTxIndex) - val n = NonceGenerator.verificationNonce(c.fundingTxId, fundingKey, commitments.localCommitIndex + 1).publicNonce + val n = NonceGenerator.verificationNonce(c.fundingTxId, fundingKey, c.remoteFundingPubKey, commitments.localCommitIndex + 1).publicNonce c.fundingTxId -> n } val tlvStream: TlvStream[RevokeAndAckTlv] = if (nonces.nonEmpty) TlvStream(RevokeAndAckTlv.NextLocalNoncesTlv(nonces.toList)) else TlvStream.empty @@ -798,17 +796,17 @@ object Helpers { TlvStream(Set( closingTxs.localAndRemote_opt.map(tx => { - val localNonce = NonceGenerator.signingNonce(localFundingKey.publicKey) + val localNonce = NonceGenerator.signingNonce(localFundingKey.publicKey, commitment.remoteFundingPubKey, commitment.fundingTxId) closingCompleteNonces = closingCompleteNonces.copy(closerAndCloseeOutputsNonce = Some(localNonce)) ClosingCompleteTlv.CloserAndCloseeOutputsPartialSignature(partialSign(tx, localNonce)) }), closingTxs.localOnly_opt.map(tx => { - val localNonce = NonceGenerator.signingNonce(localFundingKey.publicKey) + val localNonce = NonceGenerator.signingNonce(localFundingKey.publicKey, commitment.remoteFundingPubKey, commitment.fundingTxId) closingCompleteNonces = closingCompleteNonces.copy(closerOutputOnlyNonce = Some(localNonce)) ClosingCompleteTlv.CloserOutputOnlyPartialSignature(partialSign(tx, localNonce)) }), closingTxs.remoteOnly_opt.map(tx => { - val localNonce = NonceGenerator.signingNonce(localFundingKey.publicKey) + val localNonce = NonceGenerator.signingNonce(localFundingKey.publicKey, commitment.remoteFundingPubKey, commitment.fundingTxId) closingCompleteNonces = closingCompleteNonces.copy(closeeOutputOnlyNonce = Some(localNonce)) ClosingCompleteTlv.CloseeOutputOnlyPartialSignature(partialSign(tx, localNonce)) }), @@ -864,7 +862,7 @@ object Helpers { tx <- closingTx.aggregateSigs(localFundingKey.publicKey, commitment.remoteFundingPubKey, localSig, remoteSig, Map.empty) } yield (closingTx.copy(tx = tx), localSig)) match { case Right((signedClosingTx, localSig)) if signedClosingTx.validate(Map.empty) => - val nextClosingNonce = NonceGenerator.signingNonce(localFundingKey.publicKey) + val nextClosingNonce = NonceGenerator.signingNonce(localFundingKey.publicKey, commitment.remoteFundingPubKey, commitment.fundingTxId) Right(signedClosingTx, ClosingSig(commitment.channelId, remoteScriptPubkey, localScriptPubkey, closingComplete.fees, closingComplete.lockTime, TlvStream(sigToTlv(localSig.partialSig), ClosingSigTlv.NextCloseeNonce(nextClosingNonce.publicNonce))), Some(nextClosingNonce)) case _ => Left(InvalidCloseSignature(commitment.channelId, closingTx.tx.txid)) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala index c8c0d4218d..f2c43c6709 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala @@ -51,7 +51,7 @@ import fr.acinq.eclair.io.Peer.LiquidityPurchaseSigned import fr.acinq.eclair.payment.relay.Relayer import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentSettlingOnChain} import fr.acinq.eclair.router.Announcements -import fr.acinq.eclair.transactions.Transactions.{AnchorOutputsCommitmentFormat, ClosingTx, DefaultCommitmentFormat, LocalNonce, SimpleTaprootChannelCommitmentFormat} +import fr.acinq.eclair.transactions.Transactions._ import fr.acinq.eclair.transactions._ import fr.acinq.eclair.wire.protocol._ import scodec.bits.ByteVector @@ -239,7 +239,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall val tlvs: TlvStream[ShutdownTlv] = commitments.latest.commitmentFormat match { case _: SimpleTaprootChannelCommitmentFormat => val localFundingPubKey = channelKeys.fundingKey(commitments.latest.fundingTxIndex).publicKey - localClosingNonce_opt = Some(NonceGenerator.signingNonce(localFundingPubKey)) + localClosingNonce_opt = Some(NonceGenerator.signingNonce(localFundingPubKey, commitments.latest.remoteFundingPubKey, commitments.latest.fundingTxId)) TlvStream(ShutdownTlv.ShutdownNonce(localClosingNonce_opt.get.publicNonce)) case _: AnchorOutputsCommitmentFormat | DefaultCommitmentFormat => TlvStream.empty @@ -2427,8 +2427,10 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall val nextFundingTlv: Set[ChannelReestablishTlv] = Set(ChannelReestablishTlv.NextFundingTlv(d.signingSession.fundingTx.txId)) val myNextLocalNonce = d.signingSession.fundingParams.commitmentFormat match { case _: SimpleTaprootChannelCommitmentFormat => - val localNonce = NonceGenerator.verificationNonce(d.signingSession.fundingTx.txId, channelKeys.fundingKey(0), 1) - val currentCommitNonce = NonceGenerator.verificationNonce(d.signingSession.fundingTx.txId, channelKeys.fundingKey(0), d.signingSession.localCommitIndex).publicNonce + val localFundingKey = channelKeys.fundingKey(0) + val remoteFundingPubKey = d.signingSession.fundingParams.remoteFundingPubKey + val localNonce = NonceGenerator.verificationNonce(d.signingSession.fundingTx.txId, localFundingKey, remoteFundingPubKey, 1) + val currentCommitNonce = NonceGenerator.verificationNonce(d.signingSession.fundingTx.txId, localFundingKey, remoteFundingPubKey, d.signingSession.localCommitIndex).publicNonce Set(ChannelTlv.NextLocalNoncesTlv(List(d.signingSession.fundingTx.txId -> localNonce.publicNonce)), ChannelReestablishTlv.CurrentCommitNonceTlv(currentCommitNonce)) case _: AnchorOutputsCommitmentFormat | DefaultCommitmentFormat => Set.empty @@ -2490,7 +2492,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall d.commitments.active.collect { case c: Commitment if c.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => val localFundingKey = channelKeys.fundingKey(c.fundingTxIndex) - nonces.addOne(c.fundingTxId -> NonceGenerator.verificationNonce(c.fundingTxId, localFundingKey, d.commitments.localCommitIndex + 1).publicNonce) + nonces.addOne(c.fundingTxId -> NonceGenerator.verificationNonce(c.fundingTxId, localFundingKey, c.remoteFundingPubKey, d.commitments.localCommitIndex + 1).publicNonce) } val spliceStatus = d match { case d: DATA_NORMAL => Some(d.spliceStatus) @@ -2500,9 +2502,10 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall spliceStatus.collect { case w: SpliceStatus.SpliceWaitingForSigs => val localFundingKey = channelKeys.fundingKey(w.signingSession.fundingTxIndex) - nonces.addOne(w.signingSession.fundingTx.txId -> NonceGenerator.verificationNonce(w.signingSession.fundingTx.txId, localFundingKey, w.signingSession.localCommitIndex + 1).publicNonce) + val remoteFundingPubKey = w.signingSession.fundingParams.remoteFundingPubKey + nonces.addOne(w.signingSession.fundingTx.txId -> NonceGenerator.verificationNonce(w.signingSession.fundingTx.txId, localFundingKey, remoteFundingPubKey, w.signingSession.localCommitIndex + 1).publicNonce) // include a nonce for the current commitment as well, which our peer may need to re-send a commit sig for our current commit tx - currentCommitNonce.add(ChannelReestablishTlv.CurrentCommitNonceTlv(NonceGenerator.verificationNonce(w.signingSession.fundingTx.txId, localFundingKey, w.signingSession.localCommitIndex).publicNonce)) + currentCommitNonce.add(ChannelReestablishTlv.CurrentCommitNonceTlv(NonceGenerator.verificationNonce(w.signingSession.fundingTx.txId, localFundingKey, remoteFundingPubKey, w.signingSession.localCommitIndex).publicNonce)) } val myNextLocalNonces: Set[ChannelTlv.NextLocalNoncesTlv] = if (nonces.nonEmpty) Set(ChannelTlv.NextLocalNoncesTlv(nonces.toList)) else Set.empty[ChannelTlv.NextLocalNoncesTlv] @@ -2563,9 +2566,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall val fundingParams = d.signingSession.fundingParams val remoteNonce_opt = channelReestablish.currentCommitNonce_opt // remoteNextLocalNonces.get(d.signingSession.fundingTx.txId) d.signingSession.remoteCommit.sign(d.channelParams, d.signingSession.remoteCommitParams, channelKeys, d.signingSession.fundingTxIndex, fundingParams.remoteFundingPubKey, d.signingSession.commitInput(channelKeys), fundingParams.commitmentFormat, remoteNonce_opt) match { - case Left(e) => { - handleLocalError(e, d, None) - } + case Left(e) => handleLocalError(e, d, None) case Right(commitSig) => goto(WAIT_FOR_DUAL_FUNDING_SIGNED) sending commitSig } case _ => goto(WAIT_FOR_DUAL_FUNDING_SIGNED) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenSingleFunded.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenSingleFunded.scala index 9b16472fbf..4fc9220e90 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenSingleFunded.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenSingleFunded.scala @@ -19,8 +19,7 @@ package fr.acinq.eclair.channel.fsm import akka.actor.Status import akka.actor.typed.scaladsl.adapter.actorRefAdapter import akka.pattern.pipe -import fr.acinq.bitcoin.crypto.musig2.IndividualNonce -import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, SatoshiLong, TxId} +import fr.acinq.bitcoin.scalacompat.{ByteVector64, SatoshiLong} import fr.acinq.eclair.blockchain.OnChainWallet.MakeFundingTxResponse import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.channel.Helpers.Funding @@ -31,10 +30,10 @@ import fr.acinq.eclair.channel.publish.TxPublisher.SetChannelId import fr.acinq.eclair.crypto.keymanager.{LocalCommitmentKeys, RemoteCommitmentKeys} import fr.acinq.eclair.crypto.{NonceGenerator, ShaChain} import fr.acinq.eclair.io.Peer.OpenChannelResponse -import fr.acinq.eclair.transactions.{Scripts, Transactions} -import fr.acinq.eclair.transactions.Transactions.{AnchorOutputsCommitmentFormat, DefaultCommitmentFormat, SegwitV0CommitmentFormat, SimpleTaprootChannelCommitmentFormat} +import fr.acinq.eclair.transactions.Transactions +import fr.acinq.eclair.transactions.Transactions.{AnchorOutputsCommitmentFormat, DefaultCommitmentFormat, SimpleTaprootChannelCommitmentFormat} import fr.acinq.eclair.wire.protocol.{AcceptChannel, AcceptChannelTlv, AnnouncementSignatures, ChannelReady, ChannelTlv, Error, FundingCreated, FundingSigned, OpenChannel, OpenChannelTlv, PartialSignatureWithNonceTlv, TlvStream} -import fr.acinq.eclair.{MilliSatoshiLong, UInt64, randomKey, toLongId} +import fr.acinq.eclair.{MilliSatoshiLong, randomKey, toLongId} import scodec.bits.ByteVector /** @@ -73,12 +72,12 @@ trait ChannelOpenSingleFunded extends SingleFundingHandlers with ErrorHandlers { when(WAIT_FOR_INIT_SINGLE_FUNDED_CHANNEL)(handleExceptions { case Event(input: INPUT_INIT_CHANNEL_INITIATOR, _) => - val fundingPubKey = channelKeys.fundingKey(fundingTxIndex = 0).publicKey + val fundingKey = channelKeys.fundingKey(fundingTxIndex = 0) // In order to allow TLV extensions and keep backwards-compatibility, we include an empty upfront_shutdown_script if this feature is not used // See https://github.com/lightningnetwork/lightning-rfc/pull/714. val localShutdownScript = input.localChannelParams.upfrontShutdownScript_opt.getOrElse(ByteVector.empty) val localNonce = input.channelType.commitmentFormat match { - case _: SimpleTaprootChannelCommitmentFormat => Some(NonceGenerator.verificationNonce(TxId(ByteVector32.Zeroes), channelKeys.fundingKey(fundingTxIndex = 0), 0).publicNonce) + case _: SimpleTaprootChannelCommitmentFormat => Some(NonceGenerator.verificationNonce(NonceGenerator.dummyFundingTxId, fundingKey, NonceGenerator.dummyRemoteFundingPubKey, 0).publicNonce) case _: AnchorOutputsCommitmentFormat | DefaultCommitmentFormat => None } val open = OpenChannel( @@ -93,7 +92,7 @@ trait ChannelOpenSingleFunded extends SingleFundingHandlers with ErrorHandlers { feeratePerKw = input.commitTxFeerate, toSelfDelay = input.proposedCommitParams.toRemoteDelay, maxAcceptedHtlcs = input.proposedCommitParams.localMaxAcceptedHtlcs, - fundingPubkey = fundingPubKey, + fundingPubkey = fundingKey.publicKey, revocationBasepoint = channelKeys.revocationBasePoint, paymentBasepoint = input.localChannelParams.walletStaticPaymentBasepoint.getOrElse(channelKeys.paymentBasePoint), delayedPaymentBasepoint = channelKeys.delayedPaymentBasePoint, @@ -125,7 +124,7 @@ trait ChannelOpenSingleFunded extends SingleFundingHandlers with ErrorHandlers { htlcBasepoint = open.htlcBasepoint, initFeatures = d.initFundee.remoteInit.features, upfrontShutdownScript_opt = remoteShutdownScript) - val fundingPubkey = channelKeys.fundingKey(fundingTxIndex = 0).publicKey + val fundingKey = channelKeys.fundingKey(fundingTxIndex = 0) val channelParams = ChannelParams(d.initFundee.temporaryChannelId, d.initFundee.channelConfig, channelFeatures, d.initFundee.localChannelParams, remoteChannelParams, open.channelFlags) val localCommitParams = CommitParams(d.initFundee.proposedCommitParams.localDustLimit, d.initFundee.proposedCommitParams.localHtlcMinimum, d.initFundee.proposedCommitParams.localMaxHtlcValueInFlight, d.initFundee.proposedCommitParams.localMaxAcceptedHtlcs, open.toSelfDelay) val remoteCommitParams = CommitParams(open.dustLimitSatoshis, open.htlcMinimumMsat, open.maxHtlcValueInFlightMsat, open.maxAcceptedHtlcs, d.initFundee.proposedCommitParams.toRemoteDelay) @@ -133,7 +132,7 @@ trait ChannelOpenSingleFunded extends SingleFundingHandlers with ErrorHandlers { // See https://github.com/lightningnetwork/lightning-rfc/pull/714. val localShutdownScript = d.initFundee.localChannelParams.upfrontShutdownScript_opt.getOrElse(ByteVector.empty) val localNonce = d.initFundee.channelType.commitmentFormat match { - case _: SimpleTaprootChannelCommitmentFormat => Some(NonceGenerator.verificationNonce(TxId(ByteVector32.Zeroes), channelKeys.fundingKey(fundingTxIndex = 0), 0).publicNonce) + case _: SimpleTaprootChannelCommitmentFormat => Some(NonceGenerator.verificationNonce(NonceGenerator.dummyFundingTxId, fundingKey, NonceGenerator.dummyRemoteFundingPubKey, 0).publicNonce) case _: AnchorOutputsCommitmentFormat | DefaultCommitmentFormat => None } val accept = AcceptChannel(temporaryChannelId = open.temporaryChannelId, @@ -144,7 +143,7 @@ trait ChannelOpenSingleFunded extends SingleFundingHandlers with ErrorHandlers { htlcMinimumMsat = localCommitParams.htlcMinimum, toSelfDelay = remoteCommitParams.toSelfDelay, maxAcceptedHtlcs = localCommitParams.maxAcceptedHtlcs, - fundingPubkey = fundingPubkey, + fundingPubkey = fundingKey.publicKey, revocationBasepoint = channelKeys.revocationBasePoint, paymentBasepoint = d.initFundee.localChannelParams.walletStaticPaymentBasepoint.getOrElse(channelKeys.paymentBasePoint), delayedPaymentBasepoint = channelKeys.delayedPaymentBasePoint, @@ -155,7 +154,7 @@ trait ChannelOpenSingleFunded extends SingleFundingHandlers with ErrorHandlers { Some(ChannelTlv.ChannelTypeTlv(d.initFundee.channelType)), localNonce.map(n => ChannelTlv.NextLocalNonceTlv(n)) ).flatten[AcceptChannelTlv])) - setRemoteNextLocalNonces("from their open_channel", open.nexLocalNonce_opt.map(n => Map(TxId(ByteVector32.Zeroes) -> n)).getOrElse(Map.empty)) + setRemoteNextLocalNonces("from their open_channel", open.nexLocalNonce_opt.map(n => Map(NonceGenerator.dummyFundingTxId -> n)).getOrElse(Map.empty)) goto(WAIT_FOR_FUNDING_CREATED) using DATA_WAIT_FOR_FUNDING_CREATED(channelParams, d.initFundee.channelType, localCommitParams, remoteCommitParams, open.fundingSatoshis, open.pushMsat, open.feeratePerKw, open.fundingPubkey, open.firstPerCommitmentPoint) sending accept } @@ -189,7 +188,7 @@ trait ChannelOpenSingleFunded extends SingleFundingHandlers with ErrorHandlers { val channelParams = ChannelParams(d.initFunder.temporaryChannelId, d.initFunder.channelConfig, channelFeatures, d.initFunder.localChannelParams, remoteChannelParams, d.lastSent.channelFlags) val localCommitParams = CommitParams(d.initFunder.proposedCommitParams.localDustLimit, d.initFunder.proposedCommitParams.localHtlcMinimum, d.initFunder.proposedCommitParams.localMaxHtlcValueInFlight, d.initFunder.proposedCommitParams.localMaxAcceptedHtlcs, accept.toSelfDelay) val remoteCommitParams = CommitParams(accept.dustLimitSatoshis, accept.htlcMinimumMsat, accept.maxHtlcValueInFlightMsat, accept.maxAcceptedHtlcs, d.initFunder.proposedCommitParams.toRemoteDelay) - setRemoteNextLocalNonces("received AcceptChannel", accept.nexLocalNonce_opt.map(n => Map(TxId(ByteVector32.Zeroes) -> n)).getOrElse(Map.empty)) + setRemoteNextLocalNonces("received AcceptChannel", accept.nexLocalNonce_opt.map(n => Map(NonceGenerator.dummyFundingTxId -> n)).getOrElse(Map.empty)) goto(WAIT_FOR_FUNDING_INTERNAL) using DATA_WAIT_FOR_FUNDING_INTERNAL(channelParams, d.initFunder.channelType, localCommitParams, remoteCommitParams, d.initFunder.fundingAmount, d.initFunder.pushAmount_opt.getOrElse(0 msat), d.initFunder.commitTxFeerate, accept.fundingPubkey, accept.firstPerCommitmentPoint, d.initFunder.replyTo) } @@ -226,11 +225,10 @@ trait ChannelOpenSingleFunded extends SingleFundingHandlers with ErrorHandlers { // signature of their initial commitment tx that pays remote pushMsat val fundingCreated = d.commitmentFormat match { case _: SimpleTaprootChannelCommitmentFormat => - val fakeFundingTxId = TxId(ByteVector32.Zeroes) - val localNonce = NonceGenerator.verificationNonce(fakeFundingTxId, fundingKey, 0) - if (!remoteNextLocalNonces.contains(fakeFundingTxId)) throw MissingNonce(d.channelId, fakeFundingTxId) - val psig = remoteCommitTx.partialSign(fundingKey, d.remoteFundingPubKey, Map.empty, localNonce, Seq(localNonce.publicNonce, remoteNextLocalNonces(fakeFundingTxId))) match { - case Left(t) => throw InvalidNonce(d.channelId, fakeFundingTxId) + val localNonce = NonceGenerator.verificationNonce(NonceGenerator.dummyFundingTxId, fundingKey, NonceGenerator.dummyRemoteFundingPubKey, 0) + if (!remoteNextLocalNonces.contains(NonceGenerator.dummyFundingTxId)) throw MissingNonce(d.channelId, NonceGenerator.dummyFundingTxId) + val psig = remoteCommitTx.partialSign(fundingKey, d.remoteFundingPubKey, Map.empty, localNonce, Seq(localNonce.publicNonce, remoteNextLocalNonces(NonceGenerator.dummyFundingTxId))) match { + case Left(_) => throw InvalidNonce(d.channelId, NonceGenerator.dummyFundingTxId) case Right(psig) => psig } FundingCreated( @@ -281,7 +279,7 @@ trait ChannelOpenSingleFunded extends SingleFundingHandlers with ErrorHandlers { }) when(WAIT_FOR_FUNDING_CREATED)(handleExceptions { - case Event(fc@FundingCreated(_, fundingTxId, fundingTxOutputIndex, remoteSig, _), d: DATA_WAIT_FOR_FUNDING_CREATED) => + case Event(fc@FundingCreated(_, fundingTxId, fundingTxOutputIndex, _, _), d: DATA_WAIT_FOR_FUNDING_CREATED) => val temporaryChannelId = d.channelParams.channelId val fundingKey = channelKeys.fundingKey(fundingTxIndex = 0) val localCommitmentKeys = LocalCommitmentKeys(d.channelParams, channelKeys, localCommitIndex = 0, d.commitmentFormat) @@ -293,7 +291,7 @@ trait ChannelOpenSingleFunded extends SingleFundingHandlers with ErrorHandlers { // check remote signature validity val isRemoveSigValid = fc.sigOrPartialSig match { case psig: ChannelSpendSignature.PartialSignatureWithNonce if d.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => - val localNonce = NonceGenerator.verificationNonce(TxId(ByteVector32.Zeroes), fundingKey, 0) + val localNonce = NonceGenerator.verificationNonce(NonceGenerator.dummyFundingTxId, fundingKey, NonceGenerator.dummyRemoteFundingPubKey, 0) localCommitTx.checkRemotePartialSignature(fundingKey.publicKey, d.remoteFundingPubKey, psig, localNonce.publicNonce) case sig: ChannelSpendSignature.IndividualSignature if !d.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => localCommitTx.checkRemoteSig(fundingKey.publicKey, d.remoteFundingPubKey, sig) @@ -305,10 +303,10 @@ trait ChannelOpenSingleFunded extends SingleFundingHandlers with ErrorHandlers { val channelId = toLongId(fundingTxId, fundingTxOutputIndex) val fundingSigned = d.commitmentFormat match { case _: SimpleTaprootChannelCommitmentFormat => - val localNonce = NonceGenerator.verificationNonce(TxId(ByteVector32.Zeroes), fundingKey, 0) - if (!remoteNextLocalNonces.contains(TxId(ByteVector32.Zeroes))) throw MissingNonce(d.channelId, TxId(ByteVector32.Zeroes)) - val localPartialSigOfRemoteTx = remoteCommitTx.partialSign(fundingKey, d.remoteFundingPubKey, Map.empty, localNonce, Seq(localNonce.publicNonce, remoteNextLocalNonces(TxId(ByteVector32.Zeroes)))) match { - case Left(t) => throw InvalidNonce(d.channelId, TxId(ByteVector32.Zeroes)) + val localNonce = NonceGenerator.verificationNonce(NonceGenerator.dummyFundingTxId, fundingKey, NonceGenerator.dummyRemoteFundingPubKey, 0) + if (!remoteNextLocalNonces.contains(NonceGenerator.dummyFundingTxId)) throw MissingNonce(d.channelId, NonceGenerator.dummyFundingTxId) + val localPartialSigOfRemoteTx = remoteCommitTx.partialSign(fundingKey, d.remoteFundingPubKey, Map.empty, localNonce, Seq(localNonce.publicNonce, remoteNextLocalNonces(NonceGenerator.dummyFundingTxId))) match { + case Left(_) => throw InvalidNonce(d.channelId, NonceGenerator.dummyFundingTxId) case Right(psig) => psig } FundingSigned(channelId = channelId, signature = ByteVector64.Zeroes, TlvStream(PartialSignatureWithNonceTlv(localPartialSigOfRemoteTx))) @@ -358,17 +356,15 @@ trait ChannelOpenSingleFunded extends SingleFundingHandlers with ErrorHandlers { when(WAIT_FOR_FUNDING_SIGNED)(handleExceptions { case Event(fundingSigned: FundingSigned, d: DATA_WAIT_FOR_FUNDING_SIGNED) => // we make sure that their sig checks out and that our first commit tx is spendable - val fundingPubkey = channelKeys.fundingKey(fundingTxIndex = 0).publicKey - + val fundingKey = channelKeys.fundingKey(fundingTxIndex = 0) val isRemoveSigValid = fundingSigned.sigOrPartialSig match { case psig: ChannelSpendSignature.PartialSignatureWithNonce if d.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => - val localNonce = NonceGenerator.verificationNonce(TxId(ByteVector32.Zeroes), channelKeys.fundingKey(fundingTxIndex = 0), 0) - d.localCommitTx.checkRemotePartialSignature(fundingPubkey, d.remoteFundingPubKey, psig, localNonce.publicNonce) + val localNonce = NonceGenerator.verificationNonce(NonceGenerator.dummyFundingTxId, fundingKey, NonceGenerator.dummyRemoteFundingPubKey, 0) + d.localCommitTx.checkRemotePartialSignature(fundingKey.publicKey, d.remoteFundingPubKey, psig, localNonce.publicNonce) case sig: ChannelSpendSignature.IndividualSignature if !d.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => - d.localCommitTx.checkRemoteSig(fundingPubkey, d.remoteFundingPubKey, sig) + d.localCommitTx.checkRemoteSig(fundingKey.publicKey, d.remoteFundingPubKey, sig) case _ => false } - isRemoveSigValid match { case false => // we rollback the funding tx, it will never be published 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 b250cafd03..3b69d1a6bd 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 @@ -26,7 +26,6 @@ import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fsm.Channel.{BroadcastChannelUpdate, PeriodicRefresh, REFRESH_CHANNEL_UPDATE_INTERVAL} import fr.acinq.eclair.crypto.NonceGenerator import fr.acinq.eclair.db.RevokedHtlcInfoCleaner -import fr.acinq.eclair.router.Announcements import fr.acinq.eclair.transactions.Transactions.{AnchorOutputsCommitmentFormat, DefaultCommitmentFormat, SimpleTaprootChannelCommitmentFormat} import fr.acinq.eclair.wire.protocol._ import fr.acinq.eclair.{RealShortChannelId, ShortChannelId} @@ -133,7 +132,7 @@ trait CommonFundingHandlers extends CommonHandlers { val nextLocalNonce_opt = commitments.latest.commitmentFormat match { case _: SimpleTaprootChannelCommitmentFormat => val localFundingKey = channelKeys.fundingKey(fundingTxIndex = 0) - val nextLocalNonce = NonceGenerator.verificationNonce(commitments.latest.fundingTxId, localFundingKey, 1).publicNonce + val nextLocalNonce = NonceGenerator.verificationNonce(commitments.latest.fundingTxId, localFundingKey, commitments.latest.remoteFundingPubKey, 1).publicNonce Some(ChannelTlv.NextLocalNonceTlv(nextLocalNonce)) case _: AnchorOutputsCommitmentFormat | DefaultCommitmentFormat => None 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 8832fdb1f6..470b627a2c 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 @@ -473,7 +473,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon private val localNonce_opt = fundingParams.sharedInput_opt.collect { case s if s.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => val localPubKeyForSharedInput = channelKeys.fundingKey(s.fundingTxIndex).publicKey - NonceGenerator.signingNonce(localPubKeyForSharedInput) + NonceGenerator.signingNonce(localPubKeyForSharedInput, s.remoteFundingPubkey, s.info.outPoint.txid) } def start(): Behavior[Command] = { @@ -548,8 +548,8 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon ) val fundingTxId = fundingTx.txid TxComplete(fundingParams.channelId, - NonceGenerator.verificationNonce(fundingTxId, localFundingKey, purpose.localCommitIndex).publicNonce, - NonceGenerator.verificationNonce(fundingTxId, localFundingKey, purpose.localCommitIndex + 1).publicNonce, + NonceGenerator.verificationNonce(fundingTxId, localFundingKey, fundingParams.remoteFundingPubKey, purpose.localCommitIndex).publicNonce, + NonceGenerator.verificationNonce(fundingTxId, localFundingKey, fundingParams.remoteFundingPubKey, purpose.localCommitIndex + 1).publicNonce, localNonce_opt.map(_.publicNonce) ) case _: AnchorOutputsCommitmentFormat | DefaultCommitmentFormat => @@ -886,7 +886,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon def makeTlvs(): Either[ChannelException, TlvStream[CommitSigTlv]] = fundingParams.commitmentFormat match { case _: SimpleTaprootChannelCommitmentFormat => - val localNonce = NonceGenerator.signingNonce(localFundingKey.publicKey) + val localNonce = NonceGenerator.signingNonce(localFundingKey.publicKey, fundingParams.remoteFundingPubKey, fundingTx.txid) val remoteNonce = session.txCompleteReceived.flatMap(_.nonces_opt).map(_.remoteNonce) match { case Some(n) => n case None => return Left(MissingNonce(channelParams.channelId, fundingTx.txid)) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/crypto/NonceGenerator.scala b/eclair-core/src/main/scala/fr/acinq/eclair/crypto/NonceGenerator.scala index 7883159b97..02bc3a1a10 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/crypto/NonceGenerator.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/crypto/NonceGenerator.scala @@ -1,34 +1,33 @@ package fr.acinq.eclair.crypto import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} -import fr.acinq.bitcoin.scalacompat.{Musig2, TxId} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Musig2, TxId} import fr.acinq.eclair.randomBytes32 import fr.acinq.eclair.transactions.Transactions.LocalNonce import grizzled.slf4j.Logging object NonceGenerator extends Logging { + + // When using single-funding, we don't have access to the funding tx and remote funding key when creating our first + // verification nonce, so we use placeholder values instead. Note that this is fixed with dual-funding. + val dummyFundingTxId: TxId = TxId(ByteVector32.Zeroes) + val dummyRemoteFundingPubKey: PublicKey = PrivateKey(ByteVector32.One.bytes).publicKey + /** - * Deterministic nonce, used to sign our commit tx. Its public part is sent to our peer - * - * @param fundingTxId funding transaction id - * @param fundingPrivKey funding private key - * @param index commitment index - * @return a deterministic nonce + * @return a deterministic nonce used to sign our local commit tx: its public part is sent to our peer. */ - def verificationNonce(fundingTxId: TxId, fundingPrivKey: PrivateKey, index: Long): LocalNonce = { - val nonces = Musig2.generateNonceWithCounter(index, fundingPrivKey, Seq(fundingPrivKey.publicKey), None, Some(fundingTxId.value)) + def verificationNonce(fundingTxId: TxId, fundingPrivKey: PrivateKey, remoteFundingPubKey: PublicKey, commitIndex: Long): LocalNonce = { + val nonces = Musig2.generateNonceWithCounter(commitIndex, fundingPrivKey, Seq(fundingPrivKey.publicKey, remoteFundingPubKey), None, Some(fundingTxId.value)) LocalNonce(nonces._1, nonces._2) } /** - * Random nonce used to sign our peer's commit tx. - * - * @param fundingPubKey funding public key which matches the private that will be used with this nonce - * @return a random nonce + * @return a random nonce used to sign our peer's commit tx. */ - def signingNonce(fundingPubKey: PublicKey): LocalNonce = { + def signingNonce(localFundingPubKey: PublicKey, remoteFundingPubKey: PublicKey, fundingTxId: TxId): LocalNonce = { val sessionId = randomBytes32() - val nonces = Musig2.generateNonce(sessionId, Right(fundingPubKey), Seq(fundingPubKey), None, None) + val nonces = Musig2.generateNonce(sessionId, Right(localFundingPubKey), Seq(localFundingPubKey, remoteFundingPubKey), None, Some(fundingTxId.value)) LocalNonce(nonces._1, nonces._2) } + } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/crypto/NonceGeneratorSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/crypto/NonceGeneratorSpec.scala new file mode 100644 index 0000000000..917d8aa998 --- /dev/null +++ b/eclair-core/src/test/scala/fr/acinq/eclair/crypto/NonceGeneratorSpec.scala @@ -0,0 +1,59 @@ +/* + * Copyright 2025 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.crypto + +import fr.acinq.eclair.TestUtils.randomTxId +import fr.acinq.eclair.randomKey +import org.scalatest.funsuite.AnyFunSuite + +class NonceGeneratorSpec extends AnyFunSuite { + + test("generate deterministic commitment verification nonces") { + val fundingTxId1 = randomTxId() + val fundingKey1 = randomKey() + val remoteFundingKey1 = randomKey().publicKey + val fundingTxId2 = randomTxId() + val fundingKey2 = randomKey() + val remoteFundingKey2 = randomKey().publicKey + // The verification nonce changes for each commitment. + val nonces1 = (0 until 15).map(commitIndex => NonceGenerator.verificationNonce(fundingTxId1, fundingKey1, remoteFundingKey1, commitIndex)) + assert(nonces1.toSet.size == 15) + // We can re-compute verification nonces deterministically. + (0 until 15).foreach(i => assert(nonces1(i) == NonceGenerator.verificationNonce(fundingTxId1, fundingKey1, remoteFundingKey1, i))) + // Nonces for different splices are different. + val nonces2 = (0 until 15).map(commitIndex => NonceGenerator.verificationNonce(fundingTxId2, fundingKey2, remoteFundingKey2, commitIndex)) + assert((nonces1 ++ nonces2).toSet.size == 30) + // Changing any of the parameters changes the nonce value. + assert(!nonces1.contains(NonceGenerator.verificationNonce(fundingTxId2, fundingKey1, remoteFundingKey1, 3))) + assert(!nonces1.contains(NonceGenerator.verificationNonce(fundingTxId1, fundingKey2, remoteFundingKey1, 11))) + assert(!nonces1.contains(NonceGenerator.verificationNonce(fundingTxId1, fundingKey1, remoteFundingKey2, 7))) + } + + test("generate random signing nonces") { + val fundingTxId = randomTxId() + val localFundingKey = randomKey().publicKey + val remoteFundingKey = randomKey().publicKey + // Signing nonces are random and different every time, even if the parameters are the same. + val nonce1 = NonceGenerator.signingNonce(localFundingKey, remoteFundingKey, fundingTxId) + val nonce2 = NonceGenerator.signingNonce(localFundingKey, remoteFundingKey, fundingTxId) + assert(nonce1 != nonce2) + val nonce3 = NonceGenerator.signingNonce(localFundingKey, remoteFundingKey, randomTxId()) + assert(nonce3 != nonce1) + assert(nonce3 != nonce2) + } + +} From 068a9f2d4c6d00bdfa2d081673255f6d6cbc75ad Mon Sep 17 00:00:00 2001 From: t-bast Date: Thu, 31 Jul 2025 14:15:25 +0200 Subject: [PATCH 02/18] Remove `ignoreRetransmittedCommitSig` This isn't necessary since we now use `next_commitment_number` to indicate whether we want a retransmission of `commit_sig` or not on reconnection. --- .../main/scala/fr/acinq/eclair/channel/Commitments.scala | 6 ------ .../main/scala/fr/acinq/eclair/channel/fsm/Channel.scala | 8 -------- 2 files changed, 14 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala index 37e2a45895..b07ade5fdc 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala @@ -1289,12 +1289,6 @@ case class Commitments(channelParams: ChannelParams, } } - /** This function should be used to ignore a commit_sig that we've already received. */ - def ignoreRetransmittedCommitSig(channelKeys: ChannelKeys, commitSig: CommitSig): Boolean = { - val isLatestSig = false // TODO ?? - channelParams.channelFeatures.hasFeature(Features.DualFunding) && isLatestSig - } - def localFundingSigs(fundingTxId: TxId): Option[TxSignatures] = { all.find(_.fundingTxId == fundingTxId).flatMap(_.localFundingStatus.localSigs_opt) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala index f2c43c6709..6443619c4f 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala @@ -710,14 +710,6 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall stay() using d1 storing() sending signingSession1.localSigs calling endQuiescence(d1) } } - case (_, sig: CommitSig) if d.commitments.ignoreRetransmittedCommitSig(channelKeys, sig) => - // If our peer hasn't implemented https://github.com/lightning/bolts/pull/1214, they may retransmit commit_sig - // even though we've already received it and haven't requested a retransmission. It is safe to simply ignore - // this commit_sig while we wait for peers to correctly implemented commit_sig retransmission, at which point - // we should be able to get rid of this edge case. - // Note that the funding transaction may have confirmed while we were reconnecting. - log.info("ignoring commit_sig, we're still waiting for tx_signatures") - stay() case _ => // NB: in all other cases we process the commit_sigs normally. We could do a full pattern matching on all // splice statuses, but it would force us to handle every corner case where our peer doesn't behave correctly From 46b5d7187dd32af383621c41b67d415e961c3682 Mon Sep 17 00:00:00 2001 From: t-bast Date: Thu, 31 Jul 2025 14:26:41 +0200 Subject: [PATCH 03/18] Fix preimage extraction from remote HTLC-success txs When extracting the preimage from potential HTLC-success transactions, we remove the unnecessary check on the signature size, which could be gamed by our peer if they sign with a different sighash than none. --- .../scala/fr/acinq/eclair/transactions/Scripts.scala | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Scripts.scala b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Scripts.scala index 67a56f8b2c..f2a93939f6 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Scripts.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Scripts.scala @@ -17,15 +17,14 @@ package fr.acinq.eclair.transactions import fr.acinq.bitcoin.Script.LOCKTIME_THRESHOLD -import fr.acinq.bitcoin.{ScriptTree, SigHash} +import fr.acinq.bitcoin.ScriptTree import fr.acinq.bitcoin.SigHash._ import fr.acinq.bitcoin.TxIn.{SEQUENCE_LOCKTIME_DISABLE_FLAG, SEQUENCE_LOCKTIME_MASK, SEQUENCE_LOCKTIME_TYPE_FLAG} -import fr.acinq.bitcoin.io.Output import fr.acinq.bitcoin.scalacompat.Crypto.{PublicKey, XonlyPublicKey} import fr.acinq.bitcoin.scalacompat.Script._ import fr.acinq.bitcoin.scalacompat._ import fr.acinq.eclair.crypto.keymanager.{CommitmentPublicKeys, LocalCommitmentKeys, RemoteCommitmentKeys} -import fr.acinq.eclair.transactions.Transactions.{AnchorOutputsCommitmentFormat, CommitmentFormat, DefaultCommitmentFormat, SimpleTaprootChannelCommitmentFormat, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat} +import fr.acinq.eclair.transactions.Transactions._ import fr.acinq.eclair.{BlockHeight, CltvExpiry, CltvExpiryDelta} import scodec.bits.ByteVector @@ -242,7 +241,7 @@ object Scripts { /** Extract the payment preimage from a 2nd-stage HTLC Success transaction's witness script */ def extractPreimageFromHtlcSuccess: PartialFunction[ScriptWitness, ByteVector32] = { case ScriptWitness(Seq(ByteVector.empty, _, _, paymentPreimage, _)) if paymentPreimage.size == 32 => ByteVector32(paymentPreimage) - case ScriptWitness(Seq(_, sig, paymentPreimage, _, _)) if sig.size == 64 && paymentPreimage.size == 32 => ByteVector32(paymentPreimage) + case ScriptWitness(Seq(_, _, paymentPreimage, _, _)) if paymentPreimage.size == 32 => ByteVector32(paymentPreimage) } /** Extract payment preimages from a (potentially batched) 2nd-stage HTLC transaction's witnesses. */ @@ -258,7 +257,7 @@ object Scripts { /** Extract the payment preimage from from a fulfilled offered htlc. */ def extractPreimageFromClaimHtlcSuccess: PartialFunction[ScriptWitness, ByteVector32] = { case ScriptWitness(Seq(_, paymentPreimage, _)) if paymentPreimage.size == 32 => ByteVector32(paymentPreimage) - case ScriptWitness(Seq(sig, paymentPreimage, _, _)) if sig.size == 64 && paymentPreimage.size == 32 => ByteVector32(paymentPreimage) + case ScriptWitness(Seq(_, paymentPreimage, _, _)) if paymentPreimage.size == 32 => ByteVector32(paymentPreimage) } /** Extract payment preimages from a (potentially batched) claim HTLC transaction's witnesses. */ @@ -326,7 +325,7 @@ object Scripts { /** * Taproot signatures are usually 64 bytes, unless a non-default sighash is used, in which case it is appended. */ - def encodeSig(sig: ByteVector64, sighashType: Int = SIGHASH_DEFAULT): ByteVector = sighashType match { + private def encodeSig(sig: ByteVector64, sighashType: Int = SIGHASH_DEFAULT): ByteVector = sighashType match { case SIGHASH_DEFAULT | SIGHASH_ALL => sig case _ => sig :+ sighashType.toByte } From 42495f7583fa820986fea24ccde5160695f1be84 Mon Sep 17 00:00:00 2001 From: t-bast Date: Thu, 31 Jul 2025 14:34:55 +0200 Subject: [PATCH 04/18] Define signature functions without `extraUtxos` `CommitTx` and `ClosingTx` never have extra utxos, we can thus define a dedicated function overload instead of explicitly providing `Map.empty` in many places. --- .../scala/fr/acinq/eclair/channel/Commitments.scala | 6 +++--- .../main/scala/fr/acinq/eclair/channel/Helpers.scala | 10 +++++----- .../eclair/channel/fsm/ChannelOpenSingleFunded.scala | 4 ++-- .../eclair/channel/fund/InteractiveTxBuilder.scala | 2 +- .../fr/acinq/eclair/transactions/Transactions.scala | 8 ++++++++ .../acinq/eclair/transactions/TransactionsSpec.scala | 6 +++--- 6 files changed, 22 insertions(+), 14 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala index b07ade5fdc..1252d0cad4 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala @@ -212,7 +212,7 @@ case class RemoteCommit(index: Long, spec: CommitmentSpec, txId: TxId, remotePer Left(MissingNonce(channelParams.channelId, commitInput.outPoint.txid)) case _: SimpleTaprootChannelCommitmentFormat => val localNonce = NonceGenerator.signingNonce(fundingKey.publicKey, remoteFundingPubKey, commitInput.outPoint.txid) - remoteCommitTx.partialSign(fundingKey, remoteFundingPubKey, Map.empty, localNonce, Seq(localNonce.publicNonce, remoteNonce_opt.get)) match { + remoteCommitTx.partialSign(fundingKey, remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteNonce_opt.get)) match { case Left(_) => Left(InvalidNonce(channelParams.channelId, commitInput.outPoint.txid)) case Right(psig) => Right(CommitSig(channelParams.channelId, ByteVector64.Zeroes, htlcSigs.toList, TlvStream[CommitSigTlv](CommitSigTlv.PartialSignatureWithNonceTlv(psig)))) } @@ -669,7 +669,7 @@ case class Commitment(fundingTxIndex: Long, if (nextRemoteNonce_opt.isEmpty) return Left(MissingNonce(params.channelId, fundingTxId)) val Some(remoteNonce) = nextRemoteNonce_opt - val psig = remoteCommitTx.partialSign(fundingKey, remoteFundingPubKey, Map.empty, localNonce, Seq(localNonce.publicNonce, remoteNonce)) match { + val psig = remoteCommitTx.partialSign(fundingKey, remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteNonce)) match { case Left(_) => return Left(InvalidNonce(params.channelId, fundingTxId)) case Right(psig) => psig } @@ -726,7 +726,7 @@ case class Commitment(fundingTxIndex: Long, NonceGenerator.verificationNonce(fundingTxId, fundingKey, remoteFundingPubKey, localCommit.index) } (for { - partialSig <- unsignedCommitTx.partialSign(fundingKey, remoteFundingPubKey, Map.empty, localNonce, Seq(localNonce.publicNonce, remoteNonce)) + partialSig <- unsignedCommitTx.partialSign(fundingKey, remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteNonce)) aggSig <- Musig2.aggregateTaprootSignatures( Seq(partialSig.partialSig, remotePsig), unsignedCommitTx.tx, diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala index 550dacdf09..bff93e17c8 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala @@ -790,7 +790,7 @@ object Helpers { try { // generate a partial signature to send to our peer, using a random signing nonce and their closing nonce def partialSign(tx: ClosingTx, localNonce: LocalNonce): ChannelSpendSignature.PartialSignatureWithNonce = { - val Right(psig) = tx.partialSign(localFundingKey, commitment.remoteFundingPubKey, Map.empty, localNonce, Seq(localNonce.publicNonce, remoteClosingNonce_opt.get)) + val Right(psig) = tx.partialSign(localFundingKey, commitment.remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteClosingNonce_opt.get)) psig } @@ -858,8 +858,8 @@ object Helpers { val localFundingKey = channelKeys.fundingKey(commitment.fundingTxIndex) (for { // generate a local partial signature using our closing nonce (the one we sent to our peer in our Shutdown message) - localSig <- closingTx.partialSign(localFundingKey, commitment.remoteFundingPubKey, Map.empty, localClosingNonce_opt.get, Seq(localClosingNonce_opt.get.publicNonce, remoteSig.nonce)) - tx <- closingTx.aggregateSigs(localFundingKey.publicKey, commitment.remoteFundingPubKey, localSig, remoteSig, Map.empty) + localSig <- closingTx.partialSign(localFundingKey, commitment.remoteFundingPubKey, localClosingNonce_opt.get, Seq(localClosingNonce_opt.get.publicNonce, remoteSig.nonce)) + tx <- closingTx.aggregateSigs(localFundingKey.publicKey, commitment.remoteFundingPubKey, localSig, remoteSig) } yield (closingTx.copy(tx = tx), localSig)) match { case Right((signedClosingTx, localSig)) if signedClosingTx.validate(Map.empty) => val nextClosingNonce = NonceGenerator.signingNonce(localFundingKey.publicKey, commitment.remoteFundingPubKey, commitment.fundingTxId) @@ -920,8 +920,8 @@ object Helpers { ChannelSpendSignature.PartialSignatureWithNonce(remoteSig, remoteNonce_opt.get), localNonce.get.publicNonce) => Left(InvalidCloseSignature(commitment.channelId, closingTx.tx.txid)) case Some((closingTx, remoteSig, localNonce)) => (for { - localSig <- closingTx.partialSign(localFundingKey, commitment.remoteFundingPubKey, Map.empty, localNonce.get, Seq(localNonce.get.publicNonce, remoteNonce_opt.get)) - tx <- closingTx.aggregateSigs(localFundingKey.publicKey, commitment.remoteFundingPubKey, localSig, ChannelSpendSignature.PartialSignatureWithNonce(remoteSig, remoteNonce_opt.get), Map.empty) + localSig <- closingTx.partialSign(localFundingKey, commitment.remoteFundingPubKey, localNonce.get, Seq(localNonce.get.publicNonce, remoteNonce_opt.get)) + tx <- closingTx.aggregateSigs(localFundingKey.publicKey, commitment.remoteFundingPubKey, localSig, ChannelSpendSignature.PartialSignatureWithNonce(remoteSig, remoteNonce_opt.get)) signedClosingTx = closingTx.copy(tx = tx) } yield signedClosingTx) match { case Left(_) => Left(InvalidCloseSignature(commitment.channelId, closingTx.tx.txid)) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenSingleFunded.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenSingleFunded.scala index 4fc9220e90..aaf6dbc222 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenSingleFunded.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenSingleFunded.scala @@ -227,7 +227,7 @@ trait ChannelOpenSingleFunded extends SingleFundingHandlers with ErrorHandlers { case _: SimpleTaprootChannelCommitmentFormat => val localNonce = NonceGenerator.verificationNonce(NonceGenerator.dummyFundingTxId, fundingKey, NonceGenerator.dummyRemoteFundingPubKey, 0) if (!remoteNextLocalNonces.contains(NonceGenerator.dummyFundingTxId)) throw MissingNonce(d.channelId, NonceGenerator.dummyFundingTxId) - val psig = remoteCommitTx.partialSign(fundingKey, d.remoteFundingPubKey, Map.empty, localNonce, Seq(localNonce.publicNonce, remoteNextLocalNonces(NonceGenerator.dummyFundingTxId))) match { + val psig = remoteCommitTx.partialSign(fundingKey, d.remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteNextLocalNonces(NonceGenerator.dummyFundingTxId))) match { case Left(_) => throw InvalidNonce(d.channelId, NonceGenerator.dummyFundingTxId) case Right(psig) => psig } @@ -305,7 +305,7 @@ trait ChannelOpenSingleFunded extends SingleFundingHandlers with ErrorHandlers { case _: SimpleTaprootChannelCommitmentFormat => val localNonce = NonceGenerator.verificationNonce(NonceGenerator.dummyFundingTxId, fundingKey, NonceGenerator.dummyRemoteFundingPubKey, 0) if (!remoteNextLocalNonces.contains(NonceGenerator.dummyFundingTxId)) throw MissingNonce(d.channelId, NonceGenerator.dummyFundingTxId) - val localPartialSigOfRemoteTx = remoteCommitTx.partialSign(fundingKey, d.remoteFundingPubKey, Map.empty, localNonce, Seq(localNonce.publicNonce, remoteNextLocalNonces(NonceGenerator.dummyFundingTxId))) match { + val localPartialSigOfRemoteTx = remoteCommitTx.partialSign(fundingKey, d.remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteNextLocalNonces(NonceGenerator.dummyFundingTxId))) match { case Left(_) => throw InvalidNonce(d.channelId, NonceGenerator.dummyFundingTxId) case Right(psig) => psig } 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 470b627a2c..7cf8b7bf8b 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 @@ -891,7 +891,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon case Some(n) => n case None => return Left(MissingNonce(channelParams.channelId, fundingTx.txid)) } - val psig = remoteCommitTx.partialSign(localFundingKey, fundingParams.remoteFundingPubKey, Map.empty, localNonce, Seq(localNonce.publicNonce, remoteNonce)) match { + val psig = remoteCommitTx.partialSign(localFundingKey, fundingParams.remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteNonce)) match { case Left(e) => println(e) return Left(MissingNonce(channelParams.channelId, fundingTx.txid)) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala index e9e7f5b3ba..99ad32a537 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala @@ -362,6 +362,10 @@ object Transactions { override val desc: String = "commit-tx" def sign(localFundingKey: PrivateKey, remoteFundingPubkey: PublicKey): ChannelSpendSignature.IndividualSignature = sign(localFundingKey, remoteFundingPubkey, extraUtxos = Map.empty) + + def partialSign(localFundingKey: PrivateKey, remoteFundingPubkey: PublicKey, localNonce: LocalNonce, publicNonces: Seq[IndividualNonce]): Either[Throwable, ChannelSpendSignature.PartialSignatureWithNonce] = partialSign(localFundingKey, remoteFundingPubkey, extraUtxos = Map.empty, localNonce, publicNonces) + + def aggregateSigs(localFundingPubkey: PublicKey, remoteFundingPubkey: PublicKey, localSig: PartialSignatureWithNonce, remoteSig: PartialSignatureWithNonce): Either[Throwable, Transaction] = aggregateSigs(localFundingPubkey, remoteFundingPubkey, localSig, remoteSig, extraUtxos = Map.empty) } /** This transaction collaboratively spends the channel funding output (mutual-close). */ @@ -370,6 +374,10 @@ object Transactions { val toLocalOutput_opt: Option[TxOut] = toLocalOutputIndex_opt.map(i => tx.txOut(i.toInt)) def sign(localFundingKey: PrivateKey, remoteFundingPubkey: PublicKey): ChannelSpendSignature.IndividualSignature = sign(localFundingKey, remoteFundingPubkey, extraUtxos = Map.empty) + + def partialSign(localFundingKey: PrivateKey, remoteFundingPubkey: PublicKey, localNonce: LocalNonce, publicNonces: Seq[IndividualNonce]): Either[Throwable, ChannelSpendSignature.PartialSignatureWithNonce] = partialSign(localFundingKey, remoteFundingPubkey, extraUtxos = Map.empty, localNonce, publicNonces) + + def aggregateSigs(localFundingPubkey: PublicKey, remoteFundingPubkey: PublicKey, localSig: PartialSignatureWithNonce, remoteSig: PartialSignatureWithNonce): Either[Throwable, Transaction] = aggregateSigs(localFundingPubkey, remoteFundingPubkey, localSig, remoteSig, extraUtxos = Map.empty) } object ClosingTx { diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TransactionsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TransactionsSpec.scala index a8ae00261e..c48a75d1c2 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TransactionsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TransactionsSpec.scala @@ -297,12 +297,12 @@ class TransactionsSpec extends AnyFunSuite with Logging { val commitTx = commitmentFormat match { case _: SimpleTaprootChannelCommitmentFormat => val Right(commitTx) = for { - localPartialSig <- txInfo.partialSign(localFundingPriv, remoteFundingPriv.publicKey, Map.empty, LocalNonce(secretLocalNonce, publicLocalNonce), publicNonces) - remotePartialSig <- txInfo.partialSign(remoteFundingPriv, localFundingPriv.publicKey, Map.empty, LocalNonce(secretRemoteNonce, publicRemoteNonce), publicNonces) + localPartialSig <- txInfo.partialSign(localFundingPriv, remoteFundingPriv.publicKey, LocalNonce(secretLocalNonce, publicLocalNonce), publicNonces) + remotePartialSig <- txInfo.partialSign(remoteFundingPriv, localFundingPriv.publicKey, LocalNonce(secretRemoteNonce, publicRemoteNonce), publicNonces) _ = assert(txInfo.checkRemotePartialSignature(localFundingPriv.publicKey, remoteFundingPriv.publicKey, remotePartialSig, publicLocalNonce)) invalidRemotePartialSig = ChannelSpendSignature.PartialSignatureWithNonce(randomBytes32(), remotePartialSig.nonce) _ = assert(!txInfo.checkRemotePartialSignature(localFundingPriv.publicKey, remoteFundingPriv.publicKey, invalidRemotePartialSig, publicLocalNonce)) - tx <- txInfo.aggregateSigs(localFundingPriv.publicKey, remoteFundingPriv.publicKey, localPartialSig, remotePartialSig, Map.empty) + tx <- txInfo.aggregateSigs(localFundingPriv.publicKey, remoteFundingPriv.publicKey, localPartialSig, remotePartialSig) } yield tx commitTx case DefaultCommitmentFormat | _: AnchorOutputsCommitmentFormat => From 199ffb984e1d3f073df835ef91bbd052520ce595 Mon Sep 17 00:00:00 2001 From: t-bast Date: Thu, 31 Jul 2025 15:00:48 +0200 Subject: [PATCH 05/18] Refactor `channel_reestablish` local nonces creation The previous implementation didn't handle the dual-funding RBF case. We add support for this case and clean-up the local nonces TLVs created during `channel_reestablish`. --- .../fr/acinq/eclair/channel/fsm/Channel.scala | 44 ++++++++++--------- .../channel/fund/InteractiveTxBuilder.scala | 11 ++++- 2 files changed, 33 insertions(+), 22 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala index 6443619c4f..5c96395d39 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala @@ -2478,28 +2478,32 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall d.commitments.lastRemoteLocked_opt.map(c => ChannelReestablishTlv.YourLastFundingLockedTlv(c.fundingTxId)).toSet } else Set.empty - val currentCommitNonce = collection.mutable.HashSet[ChannelReestablishTlv]() - val nonces = collection.mutable.HashMap[TxId, IndividualNonce]() - // send a nonce for each active commitment - d.commitments.active.collect { + // We send our verification nonces for all active commitments. + val nextCommitNonces: Map[TxId, IndividualNonce] = d.commitments.active.collect { case c: Commitment if c.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => val localFundingKey = channelKeys.fundingKey(c.fundingTxIndex) - nonces.addOne(c.fundingTxId -> NonceGenerator.verificationNonce(c.fundingTxId, localFundingKey, c.remoteFundingPubKey, d.commitments.localCommitIndex + 1).publicNonce) - } - val spliceStatus = d match { - case d: DATA_NORMAL => Some(d.spliceStatus) - case _ => None - } - // if a splice was in progress, send a nonce for its signing session - spliceStatus.collect { - case w: SpliceStatus.SpliceWaitingForSigs => - val localFundingKey = channelKeys.fundingKey(w.signingSession.fundingTxIndex) - val remoteFundingPubKey = w.signingSession.fundingParams.remoteFundingPubKey - nonces.addOne(w.signingSession.fundingTx.txId -> NonceGenerator.verificationNonce(w.signingSession.fundingTx.txId, localFundingKey, remoteFundingPubKey, w.signingSession.localCommitIndex + 1).publicNonce) - // include a nonce for the current commitment as well, which our peer may need to re-send a commit sig for our current commit tx - currentCommitNonce.add(ChannelReestablishTlv.CurrentCommitNonceTlv(NonceGenerator.verificationNonce(w.signingSession.fundingTx.txId, localFundingKey, remoteFundingPubKey, w.signingSession.localCommitIndex).publicNonce)) + c.fundingTxId -> NonceGenerator.verificationNonce(c.fundingTxId, localFundingKey, c.remoteFundingPubKey, d.commitments.localCommitIndex + 1).publicNonce + }.toMap + // If an interactive-tx session hasn't been fully signed, we also need to include the corresponding nonces. + val (interactiveTxCurrentCommitNonce_opt, interactiveTxNextCommitNonce): (Option[IndividualNonce], Map[TxId, IndividualNonce]) = d match { + case d: DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED => d.status match { + case DualFundingStatus.RbfWaitingForSigs(signingSession) if signingSession.fundingParams.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => + val nextCommitNonce = Map(signingSession.fundingTxId -> signingSession.nextCommitNonce(channelKeys).publicNonce) + (Some(signingSession.currentCommitNonce(channelKeys).publicNonce), nextCommitNonce) + case _ => (None, Map.empty) + } + case d: DATA_NORMAL => d.spliceStatus match { + case SpliceStatus.SpliceWaitingForSigs(signingSession) if signingSession.fundingParams.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => + val nextCommitNonce = Map(signingSession.fundingTxId -> signingSession.nextCommitNonce(channelKeys).publicNonce) + (Some(signingSession.currentCommitNonce(channelKeys).publicNonce), nextCommitNonce) + case _ => (None, Map.empty) + } + case _ => (None, Map.empty) } - val myNextLocalNonces: Set[ChannelTlv.NextLocalNoncesTlv] = if (nonces.nonEmpty) Set(ChannelTlv.NextLocalNoncesTlv(nonces.toList)) else Set.empty[ChannelTlv.NextLocalNoncesTlv] + val nonceTlvs = Set( + interactiveTxCurrentCommitNonce_opt.map(nonce => ChannelReestablishTlv.CurrentCommitNonceTlv(nonce)), + if (nextCommitNonces.nonEmpty || interactiveTxNextCommitNonce.nonEmpty) Some(ChannelTlv.NextLocalNoncesTlv(nextCommitNonces.toSeq ++ interactiveTxNextCommitNonce.toSeq)) else None + ).flatten val channelReestablish = ChannelReestablish( channelId = d.channelId, @@ -2507,7 +2511,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall nextRemoteRevocationNumber = d.commitments.remoteCommitIndex, yourLastPerCommitmentSecret = PrivateKey(yourLastPerCommitmentSecret), myCurrentPerCommitmentPoint = myCurrentPerCommitmentPoint, - tlvStream = TlvStream(rbfTlv ++ lastFundingLockedTlvs ++ myNextLocalNonces ++ currentCommitNonce.toSet) + tlvStream = TlvStream(rbfTlv ++ lastFundingLockedTlvs ++ nonceTlvs) ) // we update local/remote connection-local global/local features, we don't persist it right now val d1 = Helpers.updateFeatures(d, localInit, remoteInit) 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 7cf8b7bf8b..1040fe6adf 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 @@ -1153,7 +1153,7 @@ object InteractiveTxSigningSession { aggSig match { case Right(sig) => Script.witnessKeyPathPay2tr(sig) case Left(error) => - log.warning(s"adding remote sigs for ${unsignedTx.txid} local partial sig ${localPartialSig.partialSig} is using nonce ${localPartialSig.nonce} remote partial sig ${remotePartialSig.partialSig} is using nonce ${remotePartialSig.nonce} local funding key = ${localFundingPubkey} remote funding key = ${sharedInput.remoteFundingPubkey} spent outputs = ${partiallySignedTx.tx.spentOutputs} failed with $error") + log.warning(s"adding remote sigs for ${unsignedTx.txid} local partial sig ${localPartialSig.partialSig} is using nonce ${localPartialSig.nonce} remote partial sig ${remotePartialSig.partialSig} is using nonce ${remotePartialSig.nonce} local funding key = $localFundingPubkey remote funding key = ${sharedInput.remoteFundingPubkey} spent outputs = ${partiallySignedTx.tx.spentOutputs} failed with $error") return Left(InvalidFundingSignature(fundingParams.channelId, Some(partiallySignedTx.txId))) } case _ => @@ -1201,6 +1201,7 @@ object InteractiveTxSigningSession { remoteCommitParams: CommitParams, remoteCommit: RemoteCommit, liquidityPurchase_opt: Option[LiquidityAds.PurchaseBasicInfo]) extends InteractiveTxSigningSession { + val fundingTxId: TxId = fundingTx.txId val localCommitIndex: Long = localCommit.fold(_.index, _.index) // This value tells our peer whether we need them to retransmit their commit_sig on reconnection or not. val nextLocalCommitmentNumber: Long = localCommit match { @@ -1212,12 +1213,18 @@ object InteractiveTxSigningSession { def commitInput(fundingKey: PrivateKey): InputInfo = { val fundingScript = Transactions.makeFundingScript(fundingKey.publicKey, fundingParams.remoteFundingPubKey, fundingParams.commitmentFormat).pubkeyScript - val fundingOutput = OutPoint(fundingTx.txId, fundingTx.tx.buildUnsignedTx().txOut.indexWhere(txOut => txOut.amount == fundingParams.fundingAmount && txOut.publicKeyScript == fundingScript)) + val fundingOutput = OutPoint(fundingTxId, fundingTx.tx.buildUnsignedTx().txOut.indexWhere(txOut => txOut.amount == fundingParams.fundingAmount && txOut.publicKeyScript == fundingScript)) InputInfo(fundingOutput, TxOut(fundingParams.fundingAmount, fundingScript)) } def commitInput(channelKeys: ChannelKeys): InputInfo = commitInput(localFundingKey(channelKeys)) + /** Nonce for the current commitment, which our peer will need if they must re-send their commit_sig for our current commitment transaction. */ + def currentCommitNonce(channelKeys: ChannelKeys): LocalNonce = NonceGenerator.verificationNonce(fundingTxId, localFundingKey(channelKeys), fundingParams.remoteFundingPubKey, localCommitIndex) + + /** Nonce for the next commitment, which our peer will need to sign our next commitment transaction. */ + def nextCommitNonce(channelKeys: ChannelKeys): LocalNonce = NonceGenerator.verificationNonce(fundingTxId, localFundingKey(channelKeys), fundingParams.remoteFundingPubKey, localCommitIndex + 1) + def receiveCommitSig(channelParams: ChannelParams, channelKeys: ChannelKeys, remoteCommitSig: CommitSig, currentBlockHeight: BlockHeight)(implicit log: LoggingAdapter): Either[ChannelException, InteractiveTxSigningSession] = { localCommit match { case Left(unsignedLocalCommit) => From 5b9154d543e5c4e79fe45d1cefcd65cd0d1c570e Mon Sep 17 00:00:00 2001 From: t-bast Date: Fri, 1 Aug 2025 12:08:11 +0200 Subject: [PATCH 06/18] Clean-up nonces and partial sigs TLVs and codecs We add documentation to taproot TLVs and clean-up the codec files. We clean-up the constructors for various lightning messages and better type channel spending signatures (see `commit_sig` for example). We remove the redundant `NextLocalNonce` TLV from `revoke_and_ack`. We also rename closing nonces to be more clear, as local/remote only is confusing. We add codecs tests, as the PR didn't contain any encoding test for the new TLVs. Writing those tests allowed us to discover a bug in the encoding of `txId`s, which must be encoded in reverse endianness. --- .../fr/acinq/eclair/channel/Commitments.scala | 40 +++----- .../fr/acinq/eclair/channel/Helpers.scala | 9 +- .../fr/acinq/eclair/channel/fsm/Channel.scala | 93 +++++++++---------- .../channel/fsm/ChannelOpenDualFunded.scala | 4 +- .../channel/fsm/ChannelOpenSingleFunded.scala | 18 ++-- .../channel/fsm/CommonFundingHandlers.scala | 2 +- .../eclair/channel/fsm/CommonHandlers.scala | 10 +- .../channel/fund/InteractiveTxBuilder.scala | 60 ++++++------ .../eclair/transactions/Transactions.scala | 3 + .../channel/version0/ChannelCodecs0.scala | 2 +- .../channel/version4/ChannelCodecs4.scala | 2 - .../channel/version5/ChannelCodecs5.scala | 2 +- .../eclair/wire/protocol/ChannelTlv.scala | 61 ++++++------ .../eclair/wire/protocol/CommonCodecs.scala | 1 - .../acinq/eclair/wire/protocol/HtlcTlv.scala | 18 ++-- .../wire/protocol/InteractiveTxTlv.scala | 22 ++--- .../protocol/LightningMessageCodecs.scala | 3 +- .../wire/protocol/LightningMessageTypes.scala | 61 +++++++----- .../channel/InteractiveTxBuilderSpec.scala | 19 ++-- .../a/WaitForAcceptChannelStateSpec.scala | 4 +- .../a/WaitForOpenChannelStateSpec.scala | 4 +- .../b/WaitForDualFundingSignedStateSpec.scala | 5 +- .../channel/states/e/NormalStateSpec.scala | 9 +- .../channel/states/f/ShutdownStateSpec.scala | 5 +- .../acinq/eclair/io/PeerConnectionSpec.scala | 29 +++--- .../protocol/LightningMessageCodecsSpec.scala | 50 ++++++++-- 26 files changed, 291 insertions(+), 245 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala index 1252d0cad4..362df2987f 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala @@ -5,6 +5,7 @@ import fr.acinq.bitcoin.crypto.musig2.IndividualNonce import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, Crypto, Musig2, OutPoint, Satoshi, SatoshiLong, Script, Transaction, TxId} import fr.acinq.eclair.blockchain.fee.{FeeratePerByte, FeeratePerKw, FeeratesPerKw, OnChainFeeConf} +import fr.acinq.eclair.channel.ChannelSpendSignature.{IndividualSignature, PartialSignatureWithNonce} import fr.acinq.eclair.channel.Helpers.Closing import fr.acinq.eclair.channel.Monitoring.{Metrics, Tags} import fr.acinq.eclair.channel.fsm.Channel.ChannelConf @@ -171,9 +172,9 @@ object LocalCommit { commit: CommitSig, localCommitIndex: Long, spec: CommitmentSpec, commitmentFormat: CommitmentFormat): Either[ChannelException, LocalCommit] = { val (localCommitTx, htlcTxs) = Commitment.makeLocalTxs(channelParams, commitParams, commitKeys, localCommitIndex, fundingKey, remoteFundingPubKey, commitInput, commitmentFormat, spec) val remoteCommitSigOk = commit.sigOrPartialSig match { - case ChannelSpendSignature.IndividualSignature(sig) if !commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => - localCommitTx.checkRemoteSig(fundingKey.publicKey, remoteFundingPubKey, ChannelSpendSignature.IndividualSignature(sig)) - case psig: ChannelSpendSignature.PartialSignatureWithNonce if commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => + case IndividualSignature(sig) if !commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => + localCommitTx.checkRemoteSig(fundingKey.publicKey, remoteFundingPubKey, IndividualSignature(sig)) + case psig: PartialSignatureWithNonce if commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => val localNonce = NonceGenerator.verificationNonce(fundingTxId, fundingKey, remoteFundingPubKey, localCommitIndex) localCommitTx.checkRemotePartialSignature(fundingKey.publicKey, remoteFundingPubKey, psig, localNonce.publicNonce) case _ => false @@ -206,7 +207,7 @@ case class RemoteCommit(index: Long, spec: CommitmentSpec, txId: TxId, remotePer val htlcSigs = sortedHtlcTxs.map(_.localSig(commitKeys)) commitmentFormat match { case _: SegwitV0CommitmentFormat => - val sig = remoteCommitTx.sign(fundingKey, remoteFundingPubKey).sig + val sig = remoteCommitTx.sign(fundingKey, remoteFundingPubKey) Right(CommitSig(channelParams.channelId, sig, htlcSigs.toList)) case _: SimpleTaprootChannelCommitmentFormat if remoteNonce_opt.isEmpty => Left(MissingNonce(channelParams.channelId, commitInput.outPoint.txid)) @@ -214,7 +215,7 @@ case class RemoteCommit(index: Long, spec: CommitmentSpec, txId: TxId, remotePer val localNonce = NonceGenerator.signingNonce(fundingKey.publicKey, remoteFundingPubKey, commitInput.outPoint.txid) remoteCommitTx.partialSign(fundingKey, remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteNonce_opt.get)) match { case Left(_) => Left(InvalidNonce(channelParams.channelId, commitInput.outPoint.txid)) - case Right(psig) => Right(CommitSig(channelParams.channelId, ByteVector64.Zeroes, htlcSigs.toList, TlvStream[CommitSigTlv](CommitSigTlv.PartialSignatureWithNonceTlv(psig)))) + case Right(psig) => Right(CommitSig(channelParams.channelId, psig, htlcSigs.toList)) } } } @@ -679,8 +680,8 @@ case class Commitment(fundingTxIndex: Long, None } val sig = commitmentFormat match { - case _: SimpleTaprootChannelCommitmentFormat => ByteVector64.Zeroes - case _: SegwitV0CommitmentFormat => remoteCommitTx.sign(fundingKey, remoteFundingPubKey).sig + case _: SimpleTaprootChannelCommitmentFormat => IndividualSignature(ByteVector64.Zeroes) + case _: SegwitV0CommitmentFormat => remoteCommitTx.sign(fundingKey, remoteFundingPubKey) } val tlvs = Set( if (batchSize > 1) Some(CommitSigTlv.BatchTlv(batchSize)) else None, @@ -715,10 +716,10 @@ case class Commitment(fundingTxIndex: Long, val commitKeys = localKeys(params, channelKeys) val (unsignedCommitTx, _) = Commitment.makeLocalTxs(params, localCommitParams, commitKeys, localCommit.index, fundingKey, remoteFundingPubKey, commitInput(fundingKey), commitmentFormat, localCommit.spec) localCommit.remoteSig match { - case remoteSig: ChannelSpendSignature.IndividualSignature if !commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => + case remoteSig: IndividualSignature if !commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => val localSig = unsignedCommitTx.sign(fundingKey, remoteFundingPubKey) Right(unsignedCommitTx.aggregateSigs(fundingKey.publicKey, remoteFundingPubKey, localSig, remoteSig)) - case ChannelSpendSignature.PartialSignatureWithNonce(remotePsig, remoteNonce) if commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => + case PartialSignatureWithNonce(remotePsig, remoteNonce) if commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => val localNonce = if (fundingTxIndex == 0 && localCommit.index == 0 && !params.channelFeatures.hasFeature(Features.DualFunding)) { // With channel establishment v1, we exchange the first nonce before the funding tx and remote funding key are known. NonceGenerator.verificationNonce(NonceGenerator.dummyFundingTxId, fundingKey, NonceGenerator.dummyRemoteFundingPubKey, localCommit.index) @@ -1151,26 +1152,16 @@ case class Commitments(channelParams: ChannelParams, // we will send our revocation preimage + our next revocation hash val localPerCommitmentSecret = channelKeys.commitmentSecret(localCommitIndex) val localNextPerCommitmentPoint = channelKeys.commitmentPoint(localCommitIndex + 2) - val nonces = active.collect { + val localCommitNonces = active.collect { case c: Commitment if c.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => - val fundingKey = channelKeys.fundingKey(c.fundingTxIndex) - val n = NonceGenerator.verificationNonce(c.fundingTxId, fundingKey, c.remoteFundingPubKey, localCommitIndex + 2).publicNonce - log.debug(s"revokeandack: creating verification nonce $n fundingIndex = ${c.fundingTxIndex} commit index = ${localCommitIndex + 2}") - c.fundingTxId -> n + val localNonce = NonceGenerator.verificationNonce(c.fundingTxId, c.localFundingKey(channelKeys), c.remoteFundingPubKey, localCommitIndex + 2) + c.fundingTxId -> localNonce.publicNonce } - val nextLocalNonce = if (latest.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat]) - Some(RevokeAndAckTlv.NextLocalNonceTlv(NonceGenerator.verificationNonce(latest.fundingTxId, channelKeys.fundingKey(latest.fundingTxIndex), latest.remoteFundingPubKey, localCommitIndex + 2).publicNonce)) - else None - - val tlvStream: TlvStream[RevokeAndAckTlv] = TlvStream( - Set(if (nonces.nonEmpty) Some(RevokeAndAckTlv.NextLocalNoncesTlv(nonces.toList)) else None, nextLocalNonce).flatten[RevokeAndAckTlv] - ) - val revocation = RevokeAndAck( channelId = channelId, perCommitmentSecret = localPerCommitmentSecret, nextPerCommitmentPoint = localNextPerCommitmentPoint, - tlvStream + nextLocalNonces = localCommitNonces, ) val commitments1 = copy( changes = changes.copy( @@ -1187,8 +1178,7 @@ case class Commitments(channelParams: ChannelParams, remoteNextCommitInfo match { case Right(_) => Left(UnexpectedRevocation(channelId)) case Left(_) if revocation.perCommitmentSecret.publicKey != active.head.remoteCommit.remotePerCommitmentPoint => Left(InvalidRevocation(channelId)) - case Left(_) if revocation.nexLocalNonce_opt.isEmpty && active.exists(c => c.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] && !revocation.nexLocalNonces.contains(c.fundingTxId)) => - Left(MissingNonce(channelId, latest.fundingTxId)) + case Left(_) if active.exists(c => c.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] && !revocation.nextLocalNonces.contains(c.fundingTxId)) => Left(MissingNonce(channelId, latest.fundingTxId)) case Left(_) => // Since htlcs are shared across all commitments, we generate the actions only once based on the first commitment. val receivedHtlcs = changes.remoteChanges.signed.collect { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala index bff93e17c8..875f67e0e2 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala @@ -133,7 +133,7 @@ object Helpers { val channelFeatures = ChannelFeatures(channelType, localFeatures, remoteFeatures, open.channelFlags.announceChannel) channelType.commitmentFormat match { - case _: SimpleTaprootChannelCommitmentFormat => if (open.nexLocalNonce_opt.isEmpty) return Left(MissingNonce(open.temporaryChannelId, TxId(ByteVector32.Zeroes))) + case _: SimpleTaprootChannelCommitmentFormat => if (open.nextLocalNonce_opt.isEmpty) return Left(MissingNonce(open.temporaryChannelId, TxId(ByteVector32.Zeroes))) case _: AnchorOutputsCommitmentFormat | DefaultCommitmentFormat => () } @@ -244,7 +244,7 @@ object Helpers { val channelFeatures = ChannelFeatures(channelType, localFeatures, remoteFeatures, open.channelFlags.announceChannel) channelType.commitmentFormat match { - case _: SimpleTaprootChannelCommitmentFormat => if (accept.nexLocalNonce_opt.isEmpty) return Left(MissingNonce(open.temporaryChannelId, TxId(ByteVector32.Zeroes))) + case _: SimpleTaprootChannelCommitmentFormat => if (accept.nextLocalNonce_opt.isEmpty) return Left(MissingNonce(open.temporaryChannelId, TxId(ByteVector32.Zeroes))) case _: AnchorOutputsCommitmentFormat | DefaultCommitmentFormat => () } extractShutdownScript(accept.temporaryChannelId, localFeatures, remoteFeatures, accept.upfrontShutdownScript_opt).map(script_opt => (channelFeatures, script_opt)) @@ -549,18 +549,17 @@ object Helpers { // they just sent a new commit_sig, we have received it but they didn't receive our revocation val localPerCommitmentSecret = channelKeys.commitmentSecret(commitments.localCommitIndex - 1) val localNextPerCommitmentPoint = channelKeys.commitmentPoint(commitments.localCommitIndex + 1) - val nonces = commitments.active.collect { + val localCommitNonces = commitments.active.collect { case c: Commitment if c.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => val fundingKey = channelKeys.fundingKey(c.fundingTxIndex) val n = NonceGenerator.verificationNonce(c.fundingTxId, fundingKey, c.remoteFundingPubKey, commitments.localCommitIndex + 1).publicNonce c.fundingTxId -> n } - val tlvStream: TlvStream[RevokeAndAckTlv] = if (nonces.nonEmpty) TlvStream(RevokeAndAckTlv.NextLocalNoncesTlv(nonces.toList)) else TlvStream.empty val revocation = RevokeAndAck( channelId = commitments.channelId, perCommitmentSecret = localPerCommitmentSecret, nextPerCommitmentPoint = localNextPerCommitmentPoint, - tlvStream + nextLocalNonces = localCommitNonces, ) checkRemoteCommit(remoteChannelReestablish, retransmitRevocation_opt = Some(revocation)) } else if (commitments.localCommitIndex > remoteChannelReestablish.nextRemoteRevocationNumber + 1) { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala index 5c96395d39..e1b6e9d6c2 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala @@ -223,33 +223,31 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall import Channel._ - var remoteNextLocalNonces: Map[TxId, IndividualNonce] = Map.empty - - // closing nonces are first exchanged in shutdown messages - // to generate closing_complete, nodes use random local nonces and their peer's closing nonce - // when they receive closing_complete, nodes - // - combine the chosen received partial signature with a local partial signature created with their closing nonce to build a complete signature for their closing transaction - // - reply with a closing_sig message using their closing nonce and their peer's closing nonce - // when they receive closing_sig, nodes update their remote closing nonce and can send closing_complete again - var localClosingNonce_opt: Option[LocalNonce] = None - var remoteClosingNonce_opt: Option[IndividualNonce] = None - var closingCompleteNonces: ClosingCompleteNonces = ClosingCompleteNonces(None, None, None) + // Remote nonces that must be used when signing the next remote commitment transaction (one per active commitment). + var remoteNextCommitNonces: Map[TxId, IndividualNonce] = Map.empty + + // Closee nonces are first exchanged in shutdown messages, and replaced by a new nonce after each closing_sig. + var localCloseeNonce_opt: Option[LocalNonce] = None + var remoteCloseeNonce_opt: Option[IndividualNonce] = None + // Closer nonces are randomly generated when sending our closing_complete. + var localCloserNonces: ClosingCompleteNonces = ClosingCompleteNonces(None, None, None) def createLocalShutdown(channelId: ByteVector32, finalScriptPubKey: ByteVector, commitments: Commitments): Shutdown = { - val tlvs: TlvStream[ShutdownTlv] = commitments.latest.commitmentFormat match { + commitments.latest.commitmentFormat match { case _: SimpleTaprootChannelCommitmentFormat => + // We create a fresh local closee nonce every time we send shutdown. val localFundingPubKey = channelKeys.fundingKey(commitments.latest.fundingTxIndex).publicKey - localClosingNonce_opt = Some(NonceGenerator.signingNonce(localFundingPubKey, commitments.latest.remoteFundingPubKey, commitments.latest.fundingTxId)) - TlvStream(ShutdownTlv.ShutdownNonce(localClosingNonce_opt.get.publicNonce)) + val localCloseeNonce = NonceGenerator.signingNonce(localFundingPubKey, commitments.latest.remoteFundingPubKey, commitments.latest.fundingTxId) + localCloseeNonce_opt = Some(localCloseeNonce) + Shutdown(channelId, finalScriptPubKey, localCloseeNonce.publicNonce) case _: AnchorOutputsCommitmentFormat | DefaultCommitmentFormat => - TlvStream.empty + Shutdown(channelId, finalScriptPubKey) } - Shutdown(channelId, finalScriptPubKey, tlvs) } def setRemoteNextLocalNonces(info: String, n: Map[TxId, IndividualNonce]): Unit = { - this.remoteNextLocalNonces = n - log.debug("{} set remoteNextLocalNonces to {}", info, this.remoteNextLocalNonces) + remoteNextCommitNonces = n + log.debug("{} set remoteNextLocalNonces to {}", info, this.remoteNextCommitNonces) } // we pass these to helpers classes so that they have the logging context @@ -655,7 +653,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall log.debug("ignoring CMD_SIGN (nothing to sign)") stay() case Right(_) => - d.commitments.sendCommit(channelKeys, this.remoteNextLocalNonces) match { + d.commitments.sendCommit(channelKeys, remoteNextCommitNonces) match { case Right((commitments1, commit)) => log.debug("sending a new sig, spec:\n{}", commitments1.latest.specs2String) val nextRemoteCommit = commitments1.latest.nextRemoteCommit_opt.get.commit @@ -749,7 +747,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall d.commitments.receiveRevocation(revocation, nodeParams.onChainFeeConf.feerateToleranceFor(remoteNodeId).dustTolerance.maxExposure) match { case Right((commitments1, actions)) => cancelTimer(RevocationTimeout.toString) - setRemoteNextLocalNonces("received RevokeAndAck", revocation.nexLocalNonces) + setRemoteNextLocalNonces("received RevokeAndAck", revocation.nextLocalNonces) log.debug("received a new rev, spec:\n{}", commitments1.latest.specs2String) actions.foreach { case PostRevocationAction.RelayHtlc(add) => @@ -840,11 +838,10 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall } // in the meantime we won't send new changes stay() using d.copy(remoteShutdown = Some(remoteShutdown), closeStatus_opt = Some(CloseStatus.NonInitiator(None))) - } else if (d.commitments.latest.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] && remoteShutdown.shutdownNonce_opt.isEmpty) { + } else if (d.commitments.latest.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] && remoteShutdown.closeeNonce_opt.isEmpty) { handleLocalError(MissingShutdownNonce(d.channelId), d, Some(remoteShutdown)) } else { - remoteClosingNonce_opt = remoteShutdown.shutdownNonce_opt - + remoteCloseeNonce_opt = remoteShutdown.closeeNonce_opt // so we don't have any unsigned outgoing changes val (localShutdown, sendList) = d.localShutdown match { case Some(localShutdown) => @@ -865,8 +862,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall // there are no pending signed changes, let's directly negotiate a closing transaction if (Features.canUseFeature(d.commitments.localChannelParams.initFeatures, d.commitments.remoteChannelParams.initFeatures, Features.SimpleClose)) { val (d1, closingComplete_opt) = startSimpleClose(d.commitments, localShutdown, remoteShutdown, closeStatus) - closingComplete_opt.foreach { case (_, closingCompleteNonces) => this.closingCompleteNonces = closingCompleteNonces } - goto(NEGOTIATING_SIMPLE) using d1 storing() sending sendList ++ closingComplete_opt.map(_._1).toSeq + goto(NEGOTIATING_SIMPLE) using d1 storing() sending sendList ++ closingComplete_opt.toSeq } else if (d.commitments.localChannelParams.paysClosingFees) { // we pay the closing fees, so we initiate the negotiation by sending the first closing_signed val (closingTx, closingSigned) = MutualClose.makeFirstClosingTx(channelKeys, d.commitments.latest, localShutdown.scriptPubKey, remoteShutdownScript, nodeParams.currentFeeratesForFundingClosing, nodeParams.onChainFeeConf, closeStatus.feerates_opt) @@ -1416,7 +1412,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall } case InteractiveTxBuilder.Succeeded(signingSession, commitSig, liquidityPurchase_opt, nextRemoteNonce_opt) => log.info(s"splice tx created with fundingTxIndex=${signingSession.fundingTxIndex} fundingTxId=${signingSession.fundingTx.txId}") - nextRemoteNonce_opt.foreach { case (t, n) => setRemoteNextLocalNonces("swap completed", this.remoteNextLocalNonces + (t -> n)) } + nextRemoteNonce_opt.foreach { case (t, n) => setRemoteNextLocalNonces("swap completed", remoteNextCommitNonces + (t -> n)) } cmd_opt.foreach(cmd => cmd.replyTo ! RES_SPLICE(fundingTxIndex = signingSession.fundingTxIndex, signingSession.fundingTx.txId, signingSession.fundingParams.fundingAmount, signingSession.localCommit.fold(_.spec, _.spec).toLocal)) remoteCommitSig_opt.foreach(self ! _) liquidityPurchase_opt.collect { @@ -1701,8 +1697,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall if (commitments1.hasNoPendingHtlcsOrFeeUpdate) { if (Features.canUseFeature(d.commitments.localChannelParams.initFeatures, d.commitments.remoteChannelParams.initFeatures, Features.SimpleClose)) { val (d1, closingComplete_opt) = startSimpleClose(d.commitments, localShutdown, remoteShutdown, closeStatus) - closingComplete_opt.foreach { case (_, closingCompleteNonces) => this.closingCompleteNonces = closingCompleteNonces } - goto(NEGOTIATING_SIMPLE) using d1 storing() sending revocation +: closingComplete_opt.map(_._1).toSeq + goto(NEGOTIATING_SIMPLE) using d1 storing() sending revocation +: closingComplete_opt.toSeq } else if (d.commitments.localChannelParams.paysClosingFees) { // we pay the closing fees, so we initiate the negotiation by sending the first closing_signed val (closingTx, closingSigned) = MutualClose.makeFirstClosingTx(channelKeys, commitments1.latest, localShutdown.scriptPubKey, remoteShutdown.scriptPubKey, nodeParams.currentFeeratesForFundingClosing, nodeParams.onChainFeeConf, d.closeStatus.feerates_opt) @@ -1747,8 +1742,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall log.debug("switching to NEGOTIATING spec:\n{}", commitments1.latest.specs2String) if (Features.canUseFeature(d.commitments.localChannelParams.initFeatures, d.commitments.remoteChannelParams.initFeatures, Features.SimpleClose)) { val (d1, closingComplete_opt) = startSimpleClose(d.commitments, localShutdown, remoteShutdown, closeStatus) - closingComplete_opt.foreach { case (_, closingCompleteNonces) => this.closingCompleteNonces = closingCompleteNonces } - goto(NEGOTIATING_SIMPLE) using d1 storing() sending closingComplete_opt.map(_._1).toSeq + goto(NEGOTIATING_SIMPLE) using d1 storing() sending closingComplete_opt.toSeq } else if (d.commitments.localChannelParams.paysClosingFees) { // we pay the closing fees, so we initiate the negotiation by sending the first closing_signed val (closingTx, closingSigned) = MutualClose.makeFirstClosingTx(channelKeys, commitments1.latest, localShutdown.scriptPubKey, remoteShutdown.scriptPubKey, nodeParams.currentFeeratesForFundingClosing, nodeParams.onChainFeeConf, d.closeStatus.feerates_opt) @@ -1928,9 +1922,9 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall } else { MutualClose.makeSimpleClosingTx(nodeParams.currentBlockHeight, channelKeys, d.commitments.latest, localScript, d.remoteScriptPubKey, closingFeerate) match { case Left(f) => handleCommandError(f, c) - case Right((closingTxs, closingComplete, closingCompleteNonces)) => + case Right((closingTxs, closingComplete, closerNonces)) => log.debug("signing local mutual close transactions: {}", closingTxs) - this.closingCompleteNonces = closingCompleteNonces + localCloserNonces = closerNonces handleCommandSuccess(c, d.copy(lastClosingFeerate = closingFeerate, localScriptPubKey = localScript, proposedClosingTxs = d.proposedClosingTxs :+ closingTxs)) storing() sending closingComplete } } @@ -1943,13 +1937,13 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall // No need to persist their latest script, they will re-sent it on reconnection. stay() using d.copy(remoteScriptPubKey = closingComplete.closerScriptPubKey) sending Warning(d.channelId, InvalidCloseeScript(d.channelId, closingComplete.closeeScriptPubKey, d.localScriptPubKey).getMessage) } else { - MutualClose.signSimpleClosingTx(channelKeys, d.commitments.latest, closingComplete.closeeScriptPubKey, closingComplete.closerScriptPubKey, closingComplete, this.localClosingNonce_opt) match { + MutualClose.signSimpleClosingTx(channelKeys, d.commitments.latest, closingComplete.closeeScriptPubKey, closingComplete.closerScriptPubKey, closingComplete, localCloseeNonce_opt) match { case Left(f) => log.warning("invalid closing_complete: {}", f.getMessage) stay() sending Warning(d.channelId, f.getMessage) - case Right((signedClosingTx, closingSig, nextClosingNonce_opt)) => + case Right((signedClosingTx, closingSig, nextCloseeNonce_opt)) => log.debug("signing remote mutual close transaction: {}", signedClosingTx.tx) - this.localClosingNonce_opt = nextClosingNonce_opt + localCloseeNonce_opt = nextCloseeNonce_opt val d1 = d.copy(remoteScriptPubKey = closingComplete.closerScriptPubKey, publishedClosingTxs = d.publishedClosingTxs :+ signedClosingTx) stay() using d1 storing() calling doPublish(signedClosingTx, localPaysClosingFees = false) sending closingSig } @@ -1959,15 +1953,15 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall // Note that if we sent two closing_complete in a row, without waiting for their closing_sig for the first one, // this will fail because we only care about our latest closing_complete. This is fine, we should receive their // closing_sig for the last closing_complete afterwards. - MutualClose.receiveSimpleClosingSig(channelKeys, d.commitments.latest, d.proposedClosingTxs.last, closingSig, remoteClosingNonce_opt, closingCompleteNonces) match { + MutualClose.receiveSimpleClosingSig(channelKeys, d.commitments.latest, d.proposedClosingTxs.last, closingSig, remoteCloseeNonce_opt, localCloserNonces) match { case Left(f) => log.warning("invalid closing_sig: {}", f.getMessage) + remoteCloseeNonce_opt = closingSig.nextCloseeNonce_opt stay() sending Warning(d.channelId, f.getMessage) case Right(signedClosingTx) => log.debug("received signatures for local mutual close transaction: {}", signedClosingTx.tx) val d1 = d.copy(publishedClosingTxs = d.publishedClosingTxs :+ signedClosingTx) - // update their closing nonce - this.remoteClosingNonce_opt = closingSig.nextClosingNonce_opt + remoteCloseeNonce_opt = closingSig.nextCloseeNonce_opt stay() using d1 storing() calling doPublish(signedClosingTx, localPaysClosingFees = true) } @@ -2416,16 +2410,18 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall case Event(INPUT_RECONNECTED(r, localInit, remoteInit), d: DATA_WAIT_FOR_DUAL_FUNDING_SIGNED) => activeConnection = r val myFirstPerCommitmentPoint = channelKeys.commitmentPoint(0) - val nextFundingTlv: Set[ChannelReestablishTlv] = Set(ChannelReestablishTlv.NextFundingTlv(d.signingSession.fundingTx.txId)) - val myNextLocalNonce = d.signingSession.fundingParams.commitmentFormat match { + val nextFundingTlv: Set[ChannelReestablishTlv] = Set(ChannelReestablishTlv.NextFundingTlv(d.signingSession.fundingTxId)) + val nonceTlvs = d.signingSession.fundingParams.commitmentFormat match { case _: SimpleTaprootChannelCommitmentFormat => val localFundingKey = channelKeys.fundingKey(0) val remoteFundingPubKey = d.signingSession.fundingParams.remoteFundingPubKey - val localNonce = NonceGenerator.verificationNonce(d.signingSession.fundingTx.txId, localFundingKey, remoteFundingPubKey, 1) - val currentCommitNonce = NonceGenerator.verificationNonce(d.signingSession.fundingTx.txId, localFundingKey, remoteFundingPubKey, d.signingSession.localCommitIndex).publicNonce - Set(ChannelTlv.NextLocalNoncesTlv(List(d.signingSession.fundingTx.txId -> localNonce.publicNonce)), ChannelReestablishTlv.CurrentCommitNonceTlv(currentCommitNonce)) - case _: AnchorOutputsCommitmentFormat | DefaultCommitmentFormat => - Set.empty + val currentCommitNonce = NonceGenerator.verificationNonce(d.signingSession.fundingTxId, localFundingKey, remoteFundingPubKey, 0) + val nextCommitNonce = NonceGenerator.verificationNonce(d.signingSession.fundingTxId, localFundingKey, remoteFundingPubKey, 1) + Set( + ChannelReestablishTlv.NextLocalNoncesTlv(List(d.signingSession.fundingTxId -> nextCommitNonce.publicNonce)), + ChannelReestablishTlv.CurrentCommitNonceTlv(currentCommitNonce.publicNonce), + ) + case _: AnchorOutputsCommitmentFormat | DefaultCommitmentFormat => Set.empty } val channelReestablish = ChannelReestablish( channelId = d.channelId, @@ -2433,7 +2429,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall nextRemoteRevocationNumber = 0, yourLastPerCommitmentSecret = PrivateKey(ByteVector32.Zeroes), myCurrentPerCommitmentPoint = myFirstPerCommitmentPoint, - TlvStream(nextFundingTlv ++ myNextLocalNonce), + TlvStream(nextFundingTlv ++ nonceTlvs), ) val d1 = Helpers.updateFeatures(d, localInit, remoteInit) goto(SYNCING) using d1 sending channelReestablish @@ -2502,7 +2498,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall } val nonceTlvs = Set( interactiveTxCurrentCommitNonce_opt.map(nonce => ChannelReestablishTlv.CurrentCommitNonceTlv(nonce)), - if (nextCommitNonces.nonEmpty || interactiveTxNextCommitNonce.nonEmpty) Some(ChannelTlv.NextLocalNoncesTlv(nextCommitNonces.toSeq ++ interactiveTxNextCommitNonce.toSeq)) else None + if (nextCommitNonces.nonEmpty || interactiveTxNextCommitNonce.nonEmpty) Some(ChannelReestablishTlv.NextLocalNoncesTlv(nextCommitNonces.toSeq ++ interactiveTxNextCommitNonce.toSeq)) else None ).flatten val channelReestablish = ChannelReestablish( @@ -2573,7 +2569,6 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall case Event(channelReestablish: ChannelReestablish, d: DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED) => setRemoteNextLocalNonces("received ChannelReestablish", channelReestablish.nextLocalNonces) - channelReestablish.nextFundingTxId_opt match { case Some(fundingTxId) => d.status match { @@ -2582,7 +2577,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall // They haven't received our commit_sig: we retransmit it. // We're also waiting for signatures from them, and will send our tx_signatures once we receive them. val fundingParams = signingSession.fundingParams - signingSession.remoteCommit.sign(d.commitments.channelParams, signingSession.remoteCommitParams, channelKeys, signingSession.fundingTxIndex, signingSession.fundingParams.remoteFundingPubKey, signingSession.commitInput(channelKeys), fundingParams.commitmentFormat, remoteNextLocalNonces.get(signingSession.fundingTx.txId)) match { + signingSession.remoteCommit.sign(d.commitments.channelParams, signingSession.remoteCommitParams, channelKeys, signingSession.fundingTxIndex, signingSession.fundingParams.remoteFundingPubKey, signingSession.commitInput(channelKeys), fundingParams.commitmentFormat, remoteNextCommitNonces.get(signingSession.fundingTx.txId)) match { case Left(e) => handleLocalError(e, d, None) case Right(commitSig) => goto(WAIT_FOR_DUAL_FUNDING_CONFIRMED) sending commitSig } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scala index 3a9fc04933..e0fe4641fb 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scala @@ -323,7 +323,7 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers { case Event(msg: InteractiveTxBuilder.Response, d: DATA_WAIT_FOR_DUAL_FUNDING_CREATED) => msg match { case InteractiveTxBuilder.SendMessage(_, msg) => stay() sending msg case InteractiveTxBuilder.Succeeded(status, commitSig, liquidityPurchase_opt, nextRemoteNonce_opt) => - nextRemoteNonce_opt.foreach { case (t, n) => setRemoteNextLocalNonces("dual funding completed", this.remoteNextLocalNonces + (t -> n)) } + nextRemoteNonce_opt.foreach { case (t, n) => setRemoteNextLocalNonces("dual funding completed", remoteNextCommitNonces + (t -> n)) } d.deferred.foreach(self ! _) d.replyTo_opt.foreach(_ ! OpenChannelResponse.Created(d.channelId, status.fundingTx.txId, status.fundingTx.tx.localFees.truncateToSatoshi)) liquidityPurchase_opt.collect { @@ -693,7 +693,7 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers { msg match { case InteractiveTxBuilder.SendMessage(_, msg) => stay() sending msg case InteractiveTxBuilder.Succeeded(signingSession, commitSig, liquidityPurchase_opt, nextRemoteNonce_opt) => - nextRemoteNonce_opt.foreach { case (t, n) => setRemoteNextLocalNonces("rbf completed", this.remoteNextLocalNonces + (t -> n)) } + nextRemoteNonce_opt.foreach { case (t, n) => setRemoteNextLocalNonces("rbf completed", remoteNextCommitNonces + (t -> n)) } cmd_opt.foreach(cmd => cmd.replyTo ! RES_BUMP_FUNDING_FEE(rbfIndex = d.previousFundingTxs.length, signingSession.fundingTx.txId, signingSession.fundingTx.tx.localFees.truncateToSatoshi)) remoteCommitSig_opt.foreach(self ! _) liquidityPurchase_opt.collect { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenSingleFunded.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenSingleFunded.scala index aaf6dbc222..9f7c2893f7 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenSingleFunded.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenSingleFunded.scala @@ -32,7 +32,7 @@ import fr.acinq.eclair.crypto.{NonceGenerator, ShaChain} import fr.acinq.eclair.io.Peer.OpenChannelResponse import fr.acinq.eclair.transactions.Transactions import fr.acinq.eclair.transactions.Transactions.{AnchorOutputsCommitmentFormat, DefaultCommitmentFormat, SimpleTaprootChannelCommitmentFormat} -import fr.acinq.eclair.wire.protocol.{AcceptChannel, AcceptChannelTlv, AnnouncementSignatures, ChannelReady, ChannelTlv, Error, FundingCreated, FundingSigned, OpenChannel, OpenChannelTlv, PartialSignatureWithNonceTlv, TlvStream} +import fr.acinq.eclair.wire.protocol.{AcceptChannel, AcceptChannelTlv, AnnouncementSignatures, ChannelReady, ChannelTlv, Error, FundingCreated, FundingSigned, OpenChannel, OpenChannelTlv, TlvStream} import fr.acinq.eclair.{MilliSatoshiLong, randomKey, toLongId} import scodec.bits.ByteVector @@ -154,7 +154,7 @@ trait ChannelOpenSingleFunded extends SingleFundingHandlers with ErrorHandlers { Some(ChannelTlv.ChannelTypeTlv(d.initFundee.channelType)), localNonce.map(n => ChannelTlv.NextLocalNonceTlv(n)) ).flatten[AcceptChannelTlv])) - setRemoteNextLocalNonces("from their open_channel", open.nexLocalNonce_opt.map(n => Map(NonceGenerator.dummyFundingTxId -> n)).getOrElse(Map.empty)) + setRemoteNextLocalNonces("from their open_channel", open.nextLocalNonce_opt.map(n => Map(NonceGenerator.dummyFundingTxId -> n)).getOrElse(Map.empty)) goto(WAIT_FOR_FUNDING_CREATED) using DATA_WAIT_FOR_FUNDING_CREATED(channelParams, d.initFundee.channelType, localCommitParams, remoteCommitParams, open.fundingSatoshis, open.pushMsat, open.feeratePerKw, open.fundingPubkey, open.firstPerCommitmentPoint) sending accept } @@ -188,7 +188,7 @@ trait ChannelOpenSingleFunded extends SingleFundingHandlers with ErrorHandlers { val channelParams = ChannelParams(d.initFunder.temporaryChannelId, d.initFunder.channelConfig, channelFeatures, d.initFunder.localChannelParams, remoteChannelParams, d.lastSent.channelFlags) val localCommitParams = CommitParams(d.initFunder.proposedCommitParams.localDustLimit, d.initFunder.proposedCommitParams.localHtlcMinimum, d.initFunder.proposedCommitParams.localMaxHtlcValueInFlight, d.initFunder.proposedCommitParams.localMaxAcceptedHtlcs, accept.toSelfDelay) val remoteCommitParams = CommitParams(accept.dustLimitSatoshis, accept.htlcMinimumMsat, accept.maxHtlcValueInFlightMsat, accept.maxAcceptedHtlcs, d.initFunder.proposedCommitParams.toRemoteDelay) - setRemoteNextLocalNonces("received AcceptChannel", accept.nexLocalNonce_opt.map(n => Map(NonceGenerator.dummyFundingTxId -> n)).getOrElse(Map.empty)) + setRemoteNextLocalNonces("received AcceptChannel", accept.nextLocalNonce_opt.map(n => Map(NonceGenerator.dummyFundingTxId -> n)).getOrElse(Map.empty)) goto(WAIT_FOR_FUNDING_INTERNAL) using DATA_WAIT_FOR_FUNDING_INTERNAL(channelParams, d.initFunder.channelType, localCommitParams, remoteCommitParams, d.initFunder.fundingAmount, d.initFunder.pushAmount_opt.getOrElse(0 msat), d.initFunder.commitTxFeerate, accept.fundingPubkey, accept.firstPerCommitmentPoint, d.initFunder.replyTo) } @@ -226,8 +226,8 @@ trait ChannelOpenSingleFunded extends SingleFundingHandlers with ErrorHandlers { val fundingCreated = d.commitmentFormat match { case _: SimpleTaprootChannelCommitmentFormat => val localNonce = NonceGenerator.verificationNonce(NonceGenerator.dummyFundingTxId, fundingKey, NonceGenerator.dummyRemoteFundingPubKey, 0) - if (!remoteNextLocalNonces.contains(NonceGenerator.dummyFundingTxId)) throw MissingNonce(d.channelId, NonceGenerator.dummyFundingTxId) - val psig = remoteCommitTx.partialSign(fundingKey, d.remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteNextLocalNonces(NonceGenerator.dummyFundingTxId))) match { + if (!remoteNextCommitNonces.contains(NonceGenerator.dummyFundingTxId)) throw MissingNonce(d.channelId, NonceGenerator.dummyFundingTxId) + val psig = remoteCommitTx.partialSign(fundingKey, d.remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteNextCommitNonces(NonceGenerator.dummyFundingTxId))) match { case Left(_) => throw InvalidNonce(d.channelId, NonceGenerator.dummyFundingTxId) case Right(psig) => psig } @@ -236,7 +236,7 @@ trait ChannelOpenSingleFunded extends SingleFundingHandlers with ErrorHandlers { fundingTxId = fundingTx.txid, fundingOutputIndex = fundingTxOutputIndex, signature = ByteVector64.Zeroes, - tlvStream = TlvStream(PartialSignatureWithNonceTlv(psig)) + tlvStream = TlvStream(ChannelTlv.PartialSignatureWithNonceTlv(psig)) ) case _: AnchorOutputsCommitmentFormat | DefaultCommitmentFormat => val localSigOfRemoteTx = remoteCommitTx.sign(fundingKey, d.remoteFundingPubKey).sig @@ -304,12 +304,12 @@ trait ChannelOpenSingleFunded extends SingleFundingHandlers with ErrorHandlers { val fundingSigned = d.commitmentFormat match { case _: SimpleTaprootChannelCommitmentFormat => val localNonce = NonceGenerator.verificationNonce(NonceGenerator.dummyFundingTxId, fundingKey, NonceGenerator.dummyRemoteFundingPubKey, 0) - if (!remoteNextLocalNonces.contains(NonceGenerator.dummyFundingTxId)) throw MissingNonce(d.channelId, NonceGenerator.dummyFundingTxId) - val localPartialSigOfRemoteTx = remoteCommitTx.partialSign(fundingKey, d.remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteNextLocalNonces(NonceGenerator.dummyFundingTxId))) match { + if (!remoteNextCommitNonces.contains(NonceGenerator.dummyFundingTxId)) throw MissingNonce(d.channelId, NonceGenerator.dummyFundingTxId) + val localPartialSigOfRemoteTx = remoteCommitTx.partialSign(fundingKey, d.remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteNextCommitNonces(NonceGenerator.dummyFundingTxId))) match { case Left(_) => throw InvalidNonce(d.channelId, NonceGenerator.dummyFundingTxId) case Right(psig) => psig } - FundingSigned(channelId = channelId, signature = ByteVector64.Zeroes, TlvStream(PartialSignatureWithNonceTlv(localPartialSigOfRemoteTx))) + FundingSigned(channelId = channelId, signature = ByteVector64.Zeroes, TlvStream(ChannelTlv.PartialSignatureWithNonceTlv(localPartialSigOfRemoteTx))) case _: AnchorOutputsCommitmentFormat | DefaultCommitmentFormat => val localSigOfRemoteTx = remoteCommitTx.sign(fundingKey, d.remoteFundingPubKey).sig FundingSigned(channelId = channelId, signature = localSigOfRemoteTx) 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 3b69d1a6bd..caf3b08e09 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 @@ -162,7 +162,7 @@ trait CommonFundingHandlers extends CommonHandlers { }, remoteNextCommitInfo = Right(channelReady.nextPerCommitmentPoint) ) - setRemoteNextLocalNonces("received ChannelReady", channelReady.nexLocalNonce_opt.map(n => commitments.latest.fundingTxId -> n).toMap) + setRemoteNextLocalNonces("received ChannelReady", channelReady.nextLocalNonce_opt.map(n => commitments.latest.fundingTxId -> n).toMap) peer ! ChannelReadyForPayments(self, remoteNodeId, commitments.channelId, fundingTxIndex = 0) DATA_NORMAL(commitments1, aliases1, None, initialChannelUpdate, SpliceStatus.NoSplice, None, None, None) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonHandlers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonHandlers.scala index db7039e6d5..8b83a610d8 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonHandlers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonHandlers.scala @@ -20,7 +20,6 @@ import akka.actor.FSM import fr.acinq.bitcoin.scalacompat.ByteVector32 import fr.acinq.eclair.Features import fr.acinq.eclair.channel.Helpers.Closing.MutualClose -import fr.acinq.eclair.channel.Helpers.Closing.MutualClose.ClosingCompleteNonces import fr.acinq.eclair.channel._ import fr.acinq.eclair.db.PendingCommandsDb import fr.acinq.eclair.io.Peer @@ -133,19 +132,20 @@ trait CommonHandlers { finalScriptPubkey } - def startSimpleClose(commitments: Commitments, localShutdown: Shutdown, remoteShutdown: Shutdown, closeStatus: CloseStatus): (DATA_NEGOTIATING_SIMPLE, Option[(ClosingComplete, ClosingCompleteNonces)]) = { + def startSimpleClose(commitments: Commitments, localShutdown: Shutdown, remoteShutdown: Shutdown, closeStatus: CloseStatus): (DATA_NEGOTIATING_SIMPLE, Option[ClosingComplete]) = { val localScript = localShutdown.scriptPubKey val remoteScript = remoteShutdown.scriptPubKey val closingFeerate = closeStatus.feerates_opt.map(_.preferred).getOrElse(nodeParams.onChainFeeConf.getClosingFeerate(nodeParams.currentBitcoinCoreFeerates)) - MutualClose.makeSimpleClosingTx(nodeParams.currentBlockHeight, channelKeys, commitments.latest, localScript, remoteScript, closingFeerate, remoteShutdown.shutdownNonce_opt) match { + MutualClose.makeSimpleClosingTx(nodeParams.currentBlockHeight, channelKeys, commitments.latest, localScript, remoteScript, closingFeerate, remoteShutdown.closeeNonce_opt) match { case Left(f) => log.warning("cannot create local closing txs, waiting for remote closing_complete: {}", f.getMessage) val d = DATA_NEGOTIATING_SIMPLE(commitments, closingFeerate, localScript, remoteScript, Nil, Nil) (d, None) - case Right((closingTxs, closingComplete, closingCompleteNonces)) => + case Right((closingTxs, closingComplete, closerNonces)) => log.debug("signing local mutual close transactions: {}", closingTxs) + localCloserNonces = closerNonces val d = DATA_NEGOTIATING_SIMPLE(commitments, closingFeerate, localScript, remoteScript, closingTxs :: Nil, Nil) - (d, Some(closingComplete, closingCompleteNonces)) + (d, Some(closingComplete)) } } 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 1040fe6adf..4d83f3f545 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 @@ -28,6 +28,7 @@ import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, LexicographicalOrdering, Musig2, OutPoint, Satoshi, SatoshiLong, Script, ScriptWitness, Transaction, TxId, TxIn, TxOut} import fr.acinq.eclair.blockchain.OnChainChannelFunder import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.channel.ChannelSpendSignature.{IndividualSignature, PartialSignatureWithNonce} import fr.acinq.eclair.channel.Helpers.Closing.MutualClose import fr.acinq.eclair.channel.Helpers.Funding import fr.acinq.eclair.channel._ @@ -106,7 +107,7 @@ object InteractiveTxBuilder { case class SharedFundingInput(info: InputInfo, fundingTxIndex: Long, remoteFundingPubkey: PublicKey, commitmentFormat: CommitmentFormat) { val weight: Int = commitmentFormat.fundingInputWeight - def sign(channelKeys: ChannelKeys, tx: Transaction, spentUtxos: Map[OutPoint, TxOut]): ChannelSpendSignature.IndividualSignature = { + def sign(channelKeys: ChannelKeys, tx: Transaction, spentUtxos: Map[OutPoint, TxOut]): IndividualSignature = { val localFundingKey = channelKeys.fundingKey(fundingTxIndex) Transactions.SpliceTx(info, tx).sign(localFundingKey, remoteFundingPubkey, spentUtxos) } @@ -880,14 +881,14 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon case Right((localSpec, localCommitTx, remoteSpec, remoteCommitTx, sortedHtlcTxs)) => require(fundingTx.txOut(fundingOutputIndex).publicKeyScript == localCommitTx.input.txOut.publicKeyScript, "pubkey script mismatch!") val localSigOfRemoteTx = fundingParams.commitmentFormat match { - case _: SimpleTaprootChannelCommitmentFormat => ByteVector64.Zeroes - case _: SegwitV0CommitmentFormat => remoteCommitTx.sign(localFundingKey, fundingParams.remoteFundingPubKey).sig + case _: SimpleTaprootChannelCommitmentFormat => IndividualSignature(ByteVector64.Zeroes) + case _: SegwitV0CommitmentFormat => remoteCommitTx.sign(localFundingKey, fundingParams.remoteFundingPubKey) } def makeTlvs(): Either[ChannelException, TlvStream[CommitSigTlv]] = fundingParams.commitmentFormat match { case _: SimpleTaprootChannelCommitmentFormat => val localNonce = NonceGenerator.signingNonce(localFundingKey.publicKey, fundingParams.remoteFundingPubKey, fundingTx.txid) - val remoteNonce = session.txCompleteReceived.flatMap(_.nonces_opt).map(_.remoteNonce) match { + val remoteNonce = session.txCompleteReceived.flatMap(_.nonces_opt).map(_.commitNonce) match { case Some(n) => n case None => return Left(MissingNonce(channelParams.channelId, fundingTx.txid)) } @@ -956,8 +957,8 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon ) // the last nonce they've sent becomes their "next remote nonce" val fundingTxId = validateTx(session).map(_.buildUnsignedTx().txid).getOrElse(throw new RuntimeException("invalid signing session")) - val theirNextRemoteNonce = session.txCompleteReceived.flatMap(_.nonces_opt).map(n => fundingTxId -> n.nextRemoteNonce) - replyTo ! Succeeded(signingSession, commitSig, liquidityPurchase_opt, theirNextRemoteNonce) + val theirNextCommitNonce = session.txCompleteReceived.flatMap(_.nonces_opt).map(n => fundingTxId -> n.nextCommitNonce) + replyTo ! Succeeded(signingSession, commitSig, liquidityPurchase_opt, theirNextCommitNonce) Behaviors.stopped case WalletFailure(t) => log.error("could not sign funding transaction: ", t) @@ -978,33 +979,30 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon val tx = unsignedTx.buildUnsignedTx() val sharedSig_opt = fundingParams.sharedInput_opt.map { i => i.commitmentFormat match { - case _: SimpleTaprootChannelCommitmentFormat => ByteVector64.Zeroes - case _: AnchorOutputsCommitmentFormat | DefaultCommitmentFormat => i.sign(channelKeys, tx, unsignedTx.inputDetails).sig + case _: SimpleTaprootChannelCommitmentFormat => + // there should be a single shared input + val localNonce = localNonce_opt.get + val fundingKey = channelKeys.fundingKey(i.fundingTxIndex) + val inputIndex = tx.txIn.indexWhere(_.outPoint == i.info.outPoint) + // there should be one remote nonce for each shared input ordered by serial id + val remoteNonce = session.txCompleteReceived.flatMap(_.nonces_opt).flatMap(_.fundingNonce_opt) match { + case Some(n) => n + case None => + context.self ! WalletFailure(MissingFundingNonce(channelParams.channelId)) + return + } + val psig = Musig2.signTaprootInput(fundingKey, tx, inputIndex, unsignedTx.spentOutputs, Scripts.sort(Seq(fundingKey.publicKey, i.remoteFundingPubkey)), localNonce.secretNonce, Seq(localNonce.publicNonce, remoteNonce), None) match { + case Left(_) => + context.self ! WalletFailure(InvalidFundingNonce(channelParams.channelId)) + return + case Right(psig) => psig + } + PartialSignatureWithNonce(psig, localNonce.publicNonce) + case _: AnchorOutputsCommitmentFormat | DefaultCommitmentFormat => i.sign(channelKeys, tx, unsignedTx.inputDetails) } } - val sharedPartialSig_opt: Option[ChannelSpendSignature.PartialSignatureWithNonce] = fundingParams.sharedInput_opt.collect { - case i if i.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => - // there should be a single shared input - val localNonce = this.localNonce_opt.get - val fundingKey = channelKeys.fundingKey(i.fundingTxIndex) - val inputIndex = tx.txIn.indexWhere(_.outPoint == i.info.outPoint) - // there should be one remote nonce for each shared input ordered by serial id - val remoteNonce = session.txCompleteReceived.flatMap(_.nonces_opt).flatMap(_.fundingNonce_opt) match { - case Some(n) => n - case None => - context.self ! WalletFailure(MissingFundingNonce(this.channelParams.channelId)) - return - } - val psig = Musig2.signTaprootInput(fundingKey, tx, inputIndex, unsignedTx.spentOutputs, Scripts.sort(Seq(fundingKey.publicKey, i.remoteFundingPubkey)), localNonce.secretNonce, Seq(localNonce.publicNonce, remoteNonce), None) match { - case Left(_) => - context.self ! WalletFailure(InvalidFundingNonce(this.channelParams.channelId)) - return - case Right(psig) => psig - } - ChannelSpendSignature.PartialSignatureWithNonce(psig, localNonce.publicNonce) - } if (unsignedTx.localInputs.isEmpty) { - context.self ! SignTransactionResult(PartiallySignedSharedTransaction(unsignedTx, TxSignatures(fundingParams.channelId, tx, Nil, sharedSig_opt, sharedPartialSig_opt))) + context.self ! SignTransactionResult(PartiallySignedSharedTransaction(unsignedTx, TxSignatures(fundingParams.channelId, tx, Nil, sharedSig_opt))) } else { // We track our wallet inputs and outputs, so we can verify them when we sign the transaction: if Eclair is managing bitcoin core wallet keys, it will // only sign our wallet inputs, and check that it can re-compute private keys for our wallet outputs. @@ -1037,7 +1035,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon }.sum require(actualLocalAmountOut == expectedLocalAmountOut, s"local output amount $actualLocalAmountOut does not match what we expect ($expectedLocalAmountOut): bitcoin core may be malicious") val sigs = partiallySignedTx.txIn.filter(txIn => localOutpoints.contains(txIn.outPoint)).map(_.witness) - PartiallySignedSharedTransaction(unsignedTx, TxSignatures(fundingParams.channelId, partiallySignedTx, sigs, sharedSig_opt, sharedPartialSig_opt)) + PartiallySignedSharedTransaction(unsignedTx, TxSignatures(fundingParams.channelId, partiallySignedTx, sigs, sharedSig_opt)) }) { case Failure(t) => WalletFailure(t) case Success(signedTx) => SignTransactionResult(signedTx) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala index 99ad32a537..528cc72973 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala @@ -1545,6 +1545,9 @@ object Transactions { } // @formatter:on + /** When sending [[fr.acinq.eclair.wire.protocol.ClosingComplete]], we use a random nonce for each closing transaction we create. */ + case class CloserNonces(localAndRemote_opt: Option[LocalNonce], localOnly_opt: Option[LocalNonce], remoteOnly_opt: Option[LocalNonce]) + /** Each closing attempt can result in multiple potential closing transactions, depending on which outputs are included. */ case class ClosingTxs(localAndRemote_opt: Option[ClosingTx], localOnly_opt: Option[ClosingTx], remoteOnly_opt: Option[ClosingTx]) { /** Preferred closing transaction for this closing attempt. */ diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version0/ChannelCodecs0.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version0/ChannelCodecs0.scala index 1ecc72f104..0762700dea 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version0/ChannelCodecs0.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version0/ChannelCodecs0.scala @@ -233,7 +233,7 @@ private[channel] object ChannelCodecs0 { val commitSigCodec: Codec[CommitSig] = ( ("channelId" | bytes32) :: - ("signature" | bytes64) :: + ("signature" | bytes64.as[ChannelSpendSignature.IndividualSignature]) :: ("htlcSignatures" | listofsignatures) :: ("tlvStream" | provide(TlvStream.empty[CommitSigTlv]))).as[CommitSig] diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4.scala index 559284300f..75e9de1043 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4.scala @@ -7,8 +7,6 @@ import fr.acinq.eclair.blockchain.fee.{ConfirmationPriority, ConfirmationTarget, import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fund.InteractiveTxBuilder import fr.acinq.eclair.channel.fund.InteractiveTxBuilder.{FullySignedSharedTransaction, PartiallySignedSharedTransaction} -import fr.acinq.eclair.channel.fund.InteractiveTxSigningSession.UnsignedLocalCommit -import fr.acinq.eclair.channel.fund.{InteractiveTxBuilder, InteractiveTxSigningSession} import fr.acinq.eclair.crypto.ShaChain import fr.acinq.eclair.crypto.keymanager.{LocalCommitmentKeys, RemoteCommitmentKeys} import fr.acinq.eclair.transactions.Transactions._ 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 6c7f7fa62b..eeb2480c5b 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 @@ -48,7 +48,7 @@ private[channel] object ChannelCodecs5 { private val channelSpendSignatureCodec: Codec[ChannelSpendSignature] = discriminated[ChannelSpendSignature].by(uint8) .typecase(0x01, bytes64.as[ChannelSpendSignature.IndividualSignature]) - .typecase(0x02, (("partialSig" | bytes32) :: ("nonce" | publicNonce)).as[ChannelSpendSignature.PartialSignatureWithNonce]) + .typecase(0x02, partialSignatureWithNonce) private def setCodec[T](codec: Codec[T]): Codec[Set[T]] = listOfN(uint16, codec).xmap(_.toSet, _.toList) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/ChannelTlv.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/ChannelTlv.scala index 5e06063a0a..1e9b7cdb52 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/ChannelTlv.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/ChannelTlv.scala @@ -20,8 +20,6 @@ import fr.acinq.bitcoin.crypto.musig2.IndividualNonce import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, Satoshi, TxId} import fr.acinq.eclair.channel.ChannelSpendSignature.PartialSignatureWithNonce import fr.acinq.eclair.channel.{ChannelType, ChannelTypes} -import fr.acinq.eclair.transactions.Transactions.CommitmentFormat -import fr.acinq.eclair.wire.protocol.ChannelTlv.{nextLocalNonceTlvCodec, nextLocalNoncesTlvCodec} import fr.acinq.eclair.wire.protocol.CommonCodecs._ import fr.acinq.eclair.wire.protocol.TlvCodecs.{tlvField, tlvStream, tmillisatoshi} import fr.acinq.eclair.{Alias, FeatureSupport, Features, MilliSatoshi, UInt64} @@ -93,13 +91,16 @@ object ChannelTlv { */ case class UseFeeCredit(amount: MilliSatoshi) extends OpenDualFundedChannelTlv with SpliceInitTlv + /** Verification nonce used for the next commitment transaction that will be signed (when using taproot channels). */ case class NextLocalNonceTlv(nonce: IndividualNonce) extends OpenChannelTlv with AcceptChannelTlv with ChannelReadyTlv with ClosingTlv - val nextLocalNonceTlvCodec: Codec[NextLocalNonceTlv] = tlvField(publicNonce) + val nextLocalNonceCodec: Codec[NextLocalNonceTlv] = tlvField(publicNonce) - case class NextLocalNoncesTlv(nonces: Seq[(TxId, IndividualNonce)]) extends ChannelReestablishTlv + /** Partial signature along with the signer's nonce, which is usually randomly created at signing time (when using taproot channels). */ + case class PartialSignatureWithNonceTlv(partialSigWithNonce: PartialSignatureWithNonce) extends FundingCreatedTlv with FundingSignedTlv with ClosingTlv + + val partialSignatureWithNonceCodec: Codec[PartialSignatureWithNonceTlv] = tlvField(partialSignatureWithNonce) - val nextLocalNoncesTlvCodec: Codec[NextLocalNoncesTlv] = tlvField(list(txId ~ publicNonce).xmap[Seq[(TxId, IndividualNonce)]](_.toSeq, _.toList)) } object OpenChannelTlv { @@ -109,7 +110,7 @@ object OpenChannelTlv { val openTlvCodec: Codec[TlvStream[OpenChannelTlv]] = tlvStream(discriminated[OpenChannelTlv].by(varint) .typecase(UInt64(0), upfrontShutdownScriptCodec) .typecase(UInt64(1), channelTypeCodec) - .typecase(UInt64(4), nextLocalNonceTlvCodec) + .typecase(UInt64(4), nextLocalNonceCodec) ) } @@ -121,7 +122,7 @@ object AcceptChannelTlv { val acceptTlvCodec: Codec[TlvStream[AcceptChannelTlv]] = tlvStream(discriminated[AcceptChannelTlv].by(varint) .typecase(UInt64(0), upfrontShutdownScriptCodec) .typecase(UInt64(1), channelTypeCodec) - .typecase(UInt64(4), nextLocalNonceTlvCodec) + .typecase(UInt64(4), nextLocalNonceCodec) ) } @@ -220,17 +221,11 @@ object AcceptDualFundedChannelTlv { } -case class PartialSignatureWithNonceTlv(partialSigWithNonce: PartialSignatureWithNonce) extends FundingCreatedTlv with FundingSignedTlv with ClosingTlv - -object PartialSignatureWithNonceTlv { - val codec: Codec[PartialSignatureWithNonceTlv] = tlvField(partialSignatureWithNonce) -} - sealed trait FundingCreatedTlv extends Tlv object FundingCreatedTlv { val fundingCreatedTlvCodec: Codec[TlvStream[FundingCreatedTlv]] = tlvStream(discriminated[FundingCreatedTlv].by(varint) - .typecase(UInt64(2), PartialSignatureWithNonceTlv.codec) + .typecase(UInt64(2), ChannelTlv.partialSignatureWithNonceCodec) ) } @@ -238,7 +233,7 @@ sealed trait FundingSignedTlv extends Tlv object FundingSignedTlv { val fundingSignedTlvCodec: Codec[TlvStream[FundingSignedTlv]] = tlvStream(discriminated[FundingSignedTlv].by(varint) - .typecase(UInt64(2), PartialSignatureWithNonceTlv.codec) + .typecase(UInt64(2), ChannelTlv.partialSignatureWithNonceCodec) ) } @@ -252,7 +247,7 @@ object ChannelReadyTlv { val channelReadyTlvCodec: Codec[TlvStream[ChannelReadyTlv]] = tlvStream(discriminated[ChannelReadyTlv].by(varint) .typecase(UInt64(1), channelAliasTlvCodec) - .typecase(UInt64(4), nextLocalNonceTlvCodec) + .typecase(UInt64(4), ChannelTlv.nextLocalNonceCodec) ) } @@ -265,12 +260,18 @@ object ChannelReestablishTlv { case class MyCurrentFundingLockedTlv(txId: TxId) extends ChannelReestablishTlv /** - * When disconnected during an interactive tx session, we'll include a verification nonce for our *current* commitment (using the - * session's commitment index) which our peer may need to re-send a commit sig for our current commit tx - * + * When disconnected during an interactive tx session, we'll include a verification nonce for our *current* commitment + * which our peer will need to re-send a commit sig for our current commitment transaction spending the interactive tx. */ case class CurrentCommitNonceTlv(nonce: IndividualNonce) extends ChannelReestablishTlv + /** + * Verification nonces used for the next commitment transaction, when using taproot channels. + * There must be a nonce for each active commitment (when there are pending splices or RBF attempts), indexed by the + * corresponding fundingTxId. + */ + case class NextLocalNoncesTlv(nonces: Seq[(TxId, IndividualNonce)]) extends ChannelReestablishTlv + object NextFundingTlv { val codec: Codec[NextFundingTlv] = tlvField(txIdAsHash) } @@ -287,11 +288,15 @@ object ChannelReestablishTlv { val codec: Codec[CurrentCommitNonceTlv] = tlvField("current_commit_nonce" | publicNonce) } + object NextLocalNoncesTlv { + val codec: Codec[NextLocalNoncesTlv] = tlvField(list(txIdAsHash ~ publicNonce).xmap[Seq[(TxId, IndividualNonce)]](_.toSeq, _.toList)) + } + val channelReestablishTlvCodec: Codec[TlvStream[ChannelReestablishTlv]] = tlvStream(discriminated[ChannelReestablishTlv].by(varint) .typecase(UInt64(0), NextFundingTlv.codec) .typecase(UInt64(1), YourLastFundingLockedTlv.codec) .typecase(UInt64(3), MyCurrentFundingLockedTlv.codec) - .typecase(UInt64(4), nextLocalNoncesTlvCodec) + .typecase(UInt64(4), NextLocalNoncesTlv.codec) .typecase(UInt64(6), CurrentCommitNonceTlv.codec) ) } @@ -305,6 +310,7 @@ object UpdateFeeTlv { sealed trait ShutdownTlv extends Tlv object ShutdownTlv { + /** When closing taproot channels, local nonce that will be used to sign the remote closing transaction. */ case class ShutdownNonce(nonce: IndividualNonce) extends ShutdownTlv private val shutdownNonceCodec: Codec[ShutdownNonce] = tlvField(publicNonce) @@ -339,19 +345,18 @@ object ClosingTlv { /** Signature for a closing transaction containing the closer and closee's outputs. */ case class CloserAndCloseeOutputs(sig: ByteVector64) extends ClosingTlv with ClosingCompleteTlv with ClosingSigTlv - - /** Signature for a closing transaction containing only the closer's output. */ } sealed trait ClosingCompleteTlv extends ClosingTlv object ClosingCompleteTlv { + /** When closing taproot channels, partial signature for a closing transaction containing only the closer's output. */ case class CloserOutputOnlyPartialSignature(partialSignature: PartialSignatureWithNonce) extends ClosingCompleteTlv - /** Signature for a closing transaction containing only the closee's output. */ + /** When closing taproot channels, partial signature for a closing transaction containing only the closee's output. */ case class CloseeOutputOnlyPartialSignature(partialSignature: PartialSignatureWithNonce) extends ClosingCompleteTlv - /** Signature for a closing transaction containing the closer and closee's outputs. */ + /** When closing taproot channels, partial signature for a closing transaction containing the closer and closee's outputs. */ case class CloserAndCloseeOutputsPartialSignature(partialSignature: PartialSignatureWithNonce) extends ClosingCompleteTlv val closingCompleteTlvCodec: Codec[TlvStream[ClosingCompleteTlv]] = tlvStream(discriminated[ClosingCompleteTlv].by(varint) @@ -367,15 +372,17 @@ object ClosingCompleteTlv { sealed trait ClosingSigTlv extends ClosingTlv object ClosingSigTlv { + /** When closing taproot channels, partial signature for a closing transaction containing only the closer's output. */ case class CloserOutputOnlyPartialSignature(partialSignature: ByteVector32) extends ClosingSigTlv - /** Signature for a closing transaction containing only the closee's output. */ + /** When closing taproot channels, partial signature for a closing transaction containing only the closee's output. */ case class CloseeOutputOnlyPartialSignature(partialSignature: ByteVector32) extends ClosingSigTlv - /** Signature for a closing transaction containing the closer and closee's outputs. */ + /** When closing taproot channels, partial signature for a closing transaction containing the closer and closee's outputs. */ case class CloserAndCloseeOutputsPartialSignature(partialSignature: ByteVector32) extends ClosingSigTlv - case class NextCloseeNonce(closeeNonce: IndividualNonce) extends ClosingSigTlv + /** When closing taproot channels, local nonce that will be used to sign the next remote closing transaction. */ + case class NextCloseeNonce(nonce: IndividualNonce) extends ClosingSigTlv val closingSigTlvCodec: Codec[TlvStream[ClosingSigTlv]] = tlvStream(discriminated[ClosingSigTlv].by(varint) .typecase(UInt64(1), tlvField(bytes64.as[ClosingTlv.CloserOutputOnly])) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/CommonCodecs.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/CommonCodecs.scala index 5ba4cba830..29894f80f1 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/CommonCodecs.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/CommonCodecs.scala @@ -23,7 +23,6 @@ import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.channel.ChannelSpendSignature.PartialSignatureWithNonce import fr.acinq.eclair.channel.{ChannelFlags, ShortIdAliases} import fr.acinq.eclair.crypto.Mac32 -import fr.acinq.eclair.transactions.Transactions.{CommitmentFormat, DefaultCommitmentFormat, LegacySimpleTaprootChannelCommitmentFormat, UnsafeLegacyAnchorOutputsCommitmentFormat, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat} import fr.acinq.eclair.{Alias, BlockHeight, CltvExpiry, CltvExpiryDelta, Feature, Features, InitFeature, MilliSatoshi, RealShortChannelId, ShortChannelId, TimestampSecond, UInt64, UnspecifiedShortChannelId} import fr.acinq.secp256k1.Secp256k1 import org.apache.commons.codec.binary.Base32 diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/HtlcTlv.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/HtlcTlv.scala index 029d9ec77c..5482c68fb4 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/HtlcTlv.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/HtlcTlv.scala @@ -20,8 +20,8 @@ import fr.acinq.bitcoin.crypto.musig2.IndividualNonce import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.bitcoin.scalacompat.TxId import fr.acinq.eclair.UInt64 -import fr.acinq.eclair.crypto.Sphinx import fr.acinq.eclair.channel.ChannelSpendSignature.PartialSignatureWithNonce +import fr.acinq.eclair.crypto.Sphinx import fr.acinq.eclair.wire.protocol.CommonCodecs._ import fr.acinq.eclair.wire.protocol.TlvCodecs.{tlvField, tlvStream, tu16} import scodec.bits.{ByteVector, HexStringSyntax} @@ -97,6 +97,7 @@ object CommitSigTlv { val codec: Codec[BatchTlv] = tlvField(tu16) } + /** Partial signature signature for the current commitment transaction, along with the signing nonce used (when using taproot channels). */ case class PartialSignatureWithNonceTlv(partialSigWithNonce: PartialSignatureWithNonce) extends CommitSigTlv object PartialSignatureWithNonceTlv { @@ -113,20 +114,19 @@ object CommitSigTlv { sealed trait RevokeAndAckTlv extends Tlv object RevokeAndAckTlv { + + /** + * Verification nonces used for the next commitment transaction, when using taproot channels. + * There must be a nonce for each active commitment (when there are pending splices or RBF attempts), indexed by the + * corresponding fundingTxId. + */ case class NextLocalNoncesTlv(nonces: Seq[(TxId, IndividualNonce)]) extends RevokeAndAckTlv object NextLocalNoncesTlv { - val codec: Codec[NextLocalNoncesTlv] = tlvField(list(txId ~ publicNonce).xmap[Seq[(TxId, IndividualNonce)]](_.toSeq, _.toList)) - } - - case class NextLocalNonceTlv(nonce: IndividualNonce) extends RevokeAndAckTlv - - object NextLocalNonceTlv { - val codec: Codec[NextLocalNonceTlv] = tlvField(publicNonce) + val codec: Codec[NextLocalNoncesTlv] = tlvField(list(txIdAsHash ~ publicNonce).xmap[Seq[(TxId, IndividualNonce)]](_.toSeq, _.toList)) } val revokeAndAckTlvCodec: Codec[TlvStream[RevokeAndAckTlv]] = tlvStream(discriminated[RevokeAndAckTlv].by(varint) - .typecase(UInt64(4), NextLocalNonceTlv.codec) .typecase(UInt64(6), NextLocalNoncesTlv.codec) ) } 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 908d5a9bf6..d13c38b9b9 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 @@ -63,20 +63,21 @@ sealed trait TxCompleteTlv extends Tlv object TxCompleteTlv { /** - * Musig2 nonces exchanged during an interactive tx session + * Musig2 nonces exchanged during an interactive tx session, when using a taproot channel or upgrading a channel to + * use taproot. * - * @param remoteNonce a verification nonce for the session commitment transaction - * @param nextRemoteNonce a verification nonce that will be added to the channel's nonce map once the session completes - * @param fundingNonce_opt an optional nonce to spend the session's shared input + * @param commitNonce the sender's verification nonce for the current commit tx spending the interactive tx. + * @param nextCommitNonce the sender's verification nonce for the next commit tx spending the interactive tx. + * @param fundingNonce_opt when splicing a taproot channel, the sender's random signing nonce for the previous funding output. */ - case class Nonces(remoteNonce: IndividualNonce, nextRemoteNonce: IndividualNonce, fundingNonce_opt: Option[IndividualNonce]) extends TxCompleteTlv + case class Nonces(commitNonce: IndividualNonce, nextCommitNonce: IndividualNonce, fundingNonce_opt: Option[IndividualNonce]) extends TxCompleteTlv object Nonces { - val codec: Codec[Nonces] = (publicNonce :: publicNonce :: optional(bitsRemaining, publicNonce)).as[Nonces] + val codec: Codec[Nonces] = tlvField((publicNonce :: publicNonce :: optional(bitsRemaining, publicNonce)).as[Nonces]) } val txCompleteTlvCodec: Codec[TlvStream[TxCompleteTlv]] = tlvStream(discriminated[TxCompleteTlv].by(varint) - .typecase(UInt64(4), tlvField[Nonces, Nonces](Nonces.codec)) + .typecase(UInt64(4), Nonces.codec) ) } @@ -86,14 +87,11 @@ object TxSignaturesTlv { /** When doing a splice, each peer must provide their signature for the previous 2-of-2 funding output. */ case class PreviousFundingTxSig(sig: ByteVector64) extends TxSignaturesTlv + /** When doing a splice for a taproot channel, each peer must provide their partial signature for the previous musig2 funding output. */ case class PreviousFundingTxPartialSig(partialSigWithNonce: PartialSignatureWithNonce) extends TxSignaturesTlv - object PreviousFundingTxPartialSig { - val codec: Codec[PreviousFundingTxPartialSig] = tlvField(partialSignatureWithNonce) - } - val txSignaturesTlvCodec: Codec[TlvStream[TxSignaturesTlv]] = tlvStream(discriminated[TxSignaturesTlv].by(varint) - .typecase(UInt64(2), PreviousFundingTxPartialSig.codec) + .typecase(UInt64(2), tlvField(partialSignatureWithNonce.as[PreviousFundingTxPartialSig])) .typecase(UInt64(601), tlvField(bytes64.as[PreviousFundingTxSig])) ) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala index cc796d2655..f506a3cc57 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala @@ -17,6 +17,7 @@ package fr.acinq.eclair.wire.protocol import fr.acinq.bitcoin.scalacompat.ScriptWitness +import fr.acinq.eclair.channel.ChannelSpendSignature import fr.acinq.eclair.wire.Monitoring.{Metrics, Tags} import fr.acinq.eclair.wire.protocol.CommonCodecs._ import fr.acinq.eclair.{Features, InitFeature, KamonExt} @@ -273,7 +274,7 @@ object LightningMessageCodecs { val commitSigCodec: Codec[CommitSig] = ( ("channelId" | bytes32) :: - ("signature" | bytes64) :: + ("signature" | bytes64.as[ChannelSpendSignature.IndividualSignature]) :: ("htlcSignatures" | listofsignatures) :: ("tlvStream" | CommitSigTlv.commitSigTlvCodec)).as[CommitSig] 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 633fc52f97..e83ea5c0ae 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 @@ -22,12 +22,10 @@ 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.eclair.blockchain.fee.FeeratePerKw -import fr.acinq.eclair.channel.ChannelSpendSignature.PartialSignatureWithNonce +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.CommitmentFormat import fr.acinq.eclair.wire.protocol.ChannelReadyTlv.ShortChannelIdTlv -import fr.acinq.eclair.wire.protocol.ChannelTlv.NextLocalNonceTlv import fr.acinq.eclair.{Alias, BlockHeight, CltvExpiry, CltvExpiryDelta, Feature, Features, InitFeature, MilliSatoshi, MilliSatoshiLong, RealShortChannelId, ShortChannelId, TimestampSecond, UInt64, isAsciiPrintable} import scodec.bits.ByteVector @@ -125,10 +123,8 @@ case class TxComplete(channelId: ByteVector32, } object TxComplete { - def apply(channelId: ByteVector32): TxComplete = TxComplete(channelId, TlvStream.empty) - - def apply(channelId: ByteVector32, remoteNonce: IndividualNonce, nextRemoteNonce: IndividualNonce, fundingNonce_opt: Option[IndividualNonce]): TxComplete = - TxComplete(channelId, TlvStream(TxCompleteTlv.Nonces(remoteNonce, nextRemoteNonce, fundingNonce_opt))) + def apply(channelId: ByteVector32, commitNonce: IndividualNonce, nextCommitNonce: IndividualNonce, fundingNonce_opt: Option[IndividualNonce]): TxComplete = + TxComplete(channelId, TlvStream(TxCompleteTlv.Nonces(commitNonce, nextCommitNonce, fundingNonce_opt))) } case class TxSignatures(channelId: ByteVector32, @@ -140,10 +136,12 @@ case class TxSignatures(channelId: ByteVector32, } object TxSignatures { - def apply(channelId: ByteVector32, tx: Transaction, witnesses: Seq[ScriptWitness], previousFundingSig_opt: Option[ByteVector64], previousFundingTxPartialSig_opt: Option[PartialSignatureWithNonce]): TxSignatures = { + def apply(channelId: ByteVector32, tx: Transaction, witnesses: Seq[ScriptWitness], previousFundingSig_opt: Option[ChannelSpendSignature]): TxSignatures = { val tlvs: Set[TxSignaturesTlv] = Set( - previousFundingSig_opt.map(TxSignaturesTlv.PreviousFundingTxSig), - previousFundingTxPartialSig_opt.map(p => TxSignaturesTlv.PreviousFundingTxPartialSig(p)) + previousFundingSig_opt.map { + case IndividualSignature(sig) => TxSignaturesTlv.PreviousFundingTxSig(sig) + case partialSig: PartialSignatureWithNonce => TxSignaturesTlv.PreviousFundingTxPartialSig(partialSig) + } ).flatten TxSignatures(channelId, tx.txid, witnesses, TlvStream(tlvs)) } @@ -205,7 +203,7 @@ case class ChannelReestablish(channelId: ByteVector32, val nextFundingTxId_opt: Option[TxId] = tlvStream.get[ChannelReestablishTlv.NextFundingTlv].map(_.txId) val myCurrentFundingLocked_opt: Option[TxId] = tlvStream.get[ChannelReestablishTlv.MyCurrentFundingLockedTlv].map(_.txId) val yourLastFundingLocked_opt: Option[TxId] = tlvStream.get[ChannelReestablishTlv.YourLastFundingLockedTlv].map(_.txId) - val nextLocalNonces: Map[TxId, IndividualNonce] = tlvStream.get[ChannelTlv.NextLocalNoncesTlv].map(_.nonces.toMap).getOrElse(Map.empty) + val nextLocalNonces: Map[TxId, IndividualNonce] = tlvStream.get[ChannelReestablishTlv.NextLocalNoncesTlv].map(_.nonces.toMap).getOrElse(Map.empty) val currentCommitNonce_opt: Option[IndividualNonce] = tlvStream.get[ChannelReestablishTlv.CurrentCommitNonceTlv].map(_.nonce) } @@ -230,7 +228,7 @@ case class OpenChannel(chainHash: BlockHash, tlvStream: TlvStream[OpenChannelTlv] = TlvStream.empty) extends ChannelMessage with HasTemporaryChannelId with HasChainHash { val upfrontShutdownScript_opt: Option[ByteVector] = tlvStream.get[ChannelTlv.UpfrontShutdownScriptTlv].map(_.script) val channelType_opt: Option[ChannelType] = tlvStream.get[ChannelTlv.ChannelTypeTlv].map(_.channelType) - val nexLocalNonce_opt: Option[IndividualNonce] = tlvStream.get[ChannelTlv.NextLocalNonceTlv].map(_.nonce) + val nextLocalNonce_opt: Option[IndividualNonce] = tlvStream.get[ChannelTlv.NextLocalNonceTlv].map(_.nonce) } case class AcceptChannel(temporaryChannelId: ByteVector32, @@ -250,7 +248,7 @@ case class AcceptChannel(temporaryChannelId: ByteVector32, tlvStream: TlvStream[AcceptChannelTlv] = TlvStream.empty) extends ChannelMessage with HasTemporaryChannelId { val upfrontShutdownScript_opt: Option[ByteVector] = tlvStream.get[ChannelTlv.UpfrontShutdownScriptTlv].map(_.script) val channelType_opt: Option[ChannelType] = tlvStream.get[ChannelTlv.ChannelTypeTlv].map(_.channelType) - val nexLocalNonce_opt: Option[IndividualNonce] = tlvStream.get[ChannelTlv.NextLocalNonceTlv].map(_.nonce) + val nextLocalNonce_opt: Option[IndividualNonce] = tlvStream.get[ChannelTlv.NextLocalNonceTlv].map(_.nonce) } // NB: this message is named open_channel2 in the specification. @@ -312,20 +310,20 @@ case class FundingCreated(temporaryChannelId: ByteVector32, fundingOutputIndex: Int, signature: ByteVector64, tlvStream: TlvStream[FundingCreatedTlv] = TlvStream.empty) extends ChannelMessage with HasTemporaryChannelId { - val sigOrPartialSig: ChannelSpendSignature = tlvStream.get[PartialSignatureWithNonceTlv].map(_.partialSigWithNonce).getOrElse(ChannelSpendSignature.IndividualSignature(signature)) + val sigOrPartialSig: ChannelSpendSignature = tlvStream.get[ChannelTlv.PartialSignatureWithNonceTlv].map(_.partialSigWithNonce).getOrElse(IndividualSignature(signature)) } case class FundingSigned(channelId: ByteVector32, signature: ByteVector64, tlvStream: TlvStream[FundingSignedTlv] = TlvStream.empty) extends ChannelMessage with HasChannelId { - val sigOrPartialSig: ChannelSpendSignature = tlvStream.get[PartialSignatureWithNonceTlv].map(_.partialSigWithNonce).getOrElse(ChannelSpendSignature.IndividualSignature(signature)) + val sigOrPartialSig: ChannelSpendSignature = tlvStream.get[ChannelTlv.PartialSignatureWithNonceTlv].map(_.partialSigWithNonce).getOrElse(IndividualSignature(signature)) } case class ChannelReady(channelId: ByteVector32, nextPerCommitmentPoint: PublicKey, tlvStream: TlvStream[ChannelReadyTlv] = TlvStream.empty) extends ChannelMessage with HasChannelId { val alias_opt: Option[Alias] = tlvStream.get[ShortChannelIdTlv].map(_.alias) - val nexLocalNonce_opt: Option[IndividualNonce] = tlvStream.get[ChannelTlv.NextLocalNonceTlv].map(_.nonce) + val nextLocalNonce_opt: Option[IndividualNonce] = tlvStream.get[ChannelTlv.NextLocalNonceTlv].map(_.nonce) } case class Stfu(channelId: ByteVector32, initiator: Boolean) extends SetupMessage with HasChannelId @@ -393,7 +391,11 @@ case class SpliceLocked(channelId: ByteVector32, case class Shutdown(channelId: ByteVector32, scriptPubKey: ByteVector, tlvStream: TlvStream[ShutdownTlv] = TlvStream.empty) extends ChannelMessage with HasChannelId with ForbiddenMessageWhenQuiescent { - val shutdownNonce_opt: Option[IndividualNonce] = tlvStream.get[ShutdownTlv.ShutdownNonce].map(_.nonce) + val closeeNonce_opt: Option[IndividualNonce] = tlvStream.get[ShutdownTlv.ShutdownNonce].map(_.nonce) +} + +object Shutdown { + def apply(channelId: ByteVector32, scriptPubKey: ByteVector, closeeNonce: IndividualNonce): Shutdown = Shutdown(channelId, scriptPubKey, TlvStream[ShutdownTlv](ShutdownTlv.ShutdownNonce(closeeNonce))) } case class ClosingSigned(channelId: ByteVector32, @@ -419,7 +421,7 @@ case class ClosingSig(channelId: ByteVector32, closerScriptPubKey: ByteVector, c val closerOutputOnlyPartialSig_opt: Option[ByteVector32] = tlvStream.get[ClosingSigTlv.CloserOutputOnlyPartialSignature].map(_.partialSignature) val closeeOutputOnlyPartialSig_opt: Option[ByteVector32] = tlvStream.get[ClosingSigTlv.CloseeOutputOnlyPartialSignature].map(_.partialSignature) val closerAndCloseeOutputsPartialSig_opt: Option[ByteVector32] = tlvStream.get[ClosingSigTlv.CloserAndCloseeOutputsPartialSignature].map(_.partialSignature) - val nextClosingNonce_opt: Option[IndividualNonce] = tlvStream.get[ClosingSigTlv.NextCloseeNonce].map(_.closeeNonce) + val nextCloseeNonce_opt: Option[IndividualNonce] = tlvStream.get[ClosingSigTlv.NextCloseeNonce].map(_.nonce) } case class UpdateAddHtlc(channelId: ByteVector32, @@ -488,11 +490,18 @@ object CommitSigs { } case class CommitSig(channelId: ByteVector32, - signature: ByteVector64, + signature: IndividualSignature, htlcSignatures: List[ByteVector64], tlvStream: TlvStream[CommitSigTlv] = TlvStream.empty) extends CommitSigs { val partialSignature_opt: Option[PartialSignatureWithNonce] = tlvStream.get[CommitSigTlv.PartialSignatureWithNonceTlv].map(_.partialSigWithNonce) - val sigOrPartialSig: ChannelSpendSignature = partialSignature_opt.getOrElse(ChannelSpendSignature.IndividualSignature(signature)) + val sigOrPartialSig: ChannelSpendSignature = partialSignature_opt.getOrElse(signature) +} + +object CommitSig { + def apply(channelId: ByteVector32, signature: PartialSignatureWithNonce, htlcSignatures: List[ByteVector64]): CommitSig = { + val emptySig = IndividualSignature(ByteVector64.Zeroes) + CommitSig(channelId, emptySig, htlcSignatures, TlvStream(CommitSigTlv.PartialSignatureWithNonceTlv(signature))) + } } case class CommitSigBatch(messages: Seq[CommitSig]) extends CommitSigs { @@ -505,8 +514,16 @@ case class RevokeAndAck(channelId: ByteVector32, perCommitmentSecret: PrivateKey, nextPerCommitmentPoint: PublicKey, tlvStream: TlvStream[RevokeAndAckTlv] = TlvStream.empty) extends HtlcMessage with HasChannelId { - val nexLocalNonces: Map[TxId, IndividualNonce] = tlvStream.get[RevokeAndAckTlv.NextLocalNoncesTlv].map(_.nonces.toMap).getOrElse(Map.empty) - val nexLocalNonce_opt: Option[IndividualNonce] = tlvStream.get[RevokeAndAckTlv.NextLocalNonceTlv].map(_.nonce) + val nextLocalNonces: Map[TxId, IndividualNonce] = tlvStream.get[RevokeAndAckTlv.NextLocalNoncesTlv].map(_.nonces.toMap).getOrElse(Map.empty) +} + +object RevokeAndAck { + def apply(channelId: ByteVector32, perCommitmentSecret: PrivateKey, nextPerCommitmentPoint: PublicKey, nextLocalNonces: Seq[(TxId, IndividualNonce)]): RevokeAndAck = { + val tlvs = Set( + if (nextLocalNonces.nonEmpty) Some(RevokeAndAckTlv.NextLocalNoncesTlv(nextLocalNonces)) else None + ).flatten[RevokeAndAckTlv] + RevokeAndAck(channelId, perCommitmentSecret, nextPerCommitmentPoint, TlvStream(tlvs)) + } } case class UpdateFee(channelId: ByteVector32, 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 999c72ef76..5bbd01413a 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 @@ -32,11 +32,12 @@ import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.{MempoolTx, Utx import fr.acinq.eclair.blockchain.bitcoind.rpc.{BitcoinCoreClient, BitcoinJsonRPCClient} import fr.acinq.eclair.blockchain.fee.{FeeratePerByte, FeeratePerKw} import fr.acinq.eclair.blockchain.{OnChainWallet, SingleKeyOnChainWallet} +import fr.acinq.eclair.channel.ChannelSpendSignature.{IndividualSignature, PartialSignatureWithNonce} import fr.acinq.eclair.channel.fund.InteractiveTxBuilder._ import fr.acinq.eclair.channel.fund.{InteractiveTxBuilder, InteractiveTxSigningSession} import fr.acinq.eclair.crypto.keymanager.ChannelKeys import fr.acinq.eclair.io.OpenChannelInterceptor.makeChannelParams -import fr.acinq.eclair.transactions.Transactions.{CommitmentFormat, InputInfo, SimpleTaprootChannelCommitmentFormat} +import fr.acinq.eclair.transactions.Transactions.{InputInfo, SimpleTaprootChannelCommitmentFormat} import fr.acinq.eclair.transactions.{Scripts, Transactions} import fr.acinq.eclair.wire.protocol._ import fr.acinq.eclair.{Feature, FeatureSupport, Features, InitFeature, MilliSatoshiLong, NodeParams, TestConstants, TestKitBaseClass, ToMilliSatoshiConversion, UInt64, randomBytes32, randomKey} @@ -285,8 +286,10 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit msg match { case tc: TxComplete => tc.nonces_opt.foreach(nonces => { - if (txCompleteNonces.contains(nonces.remoteNonce)) fail("nonce reuse") else txCompleteNonces.add(nonces.remoteNonce) - if (txCompleteNonces.contains(nonces.nextRemoteNonce)) fail("nonce reuse") else txCompleteNonces.add(nonces.nextRemoteNonce) + assert(!txCompleteNonces.contains(nonces.commitNonce), "commit nonce reuse") + assert(!txCompleteNonces.contains(nonces.nextCommitNonce), "next commit nonce reuse") + txCompleteNonces.add(nonces.commitNonce) + txCompleteNonces.add(nonces.nextCommitNonce) }) case _ => () } @@ -562,7 +565,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit // Alice --- tx_add_input --> Bob fwd.forwardAlice2Bob[TxAddInput] // Alice <-- tx_complete --- Bob - val foo = fwd.forwardBob2Alice[TxComplete] + fwd.forwardBob2Alice[TxComplete] // Alice --- tx_add_input --> Bob fwd.forwardAlice2Bob[TxAddInput] // Alice <-- tx_complete --- Bob @@ -2785,8 +2788,8 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit case _: SimpleTaprootChannelCommitmentFormat => val priv = randomKey() val (_, nonce) = Musig2.generateNonce(randomBytes32(), Left(priv), Seq(priv.publicKey), None, None) - CommitSig(params.channelId, ByteVector64.Zeroes, Nil, TlvStream(CommitSigTlv.PartialSignatureWithNonceTlv(ChannelSpendSignature.PartialSignatureWithNonce(ByteVector32.Zeroes, nonce)))) - case _ => CommitSig(params.channelId, ByteVector64.Zeroes, Nil) + CommitSig(params.channelId, PartialSignatureWithNonce(ByteVector32.Zeroes, nonce), Nil) + case _ => CommitSig(params.channelId, IndividualSignature(ByteVector64.Zeroes), Nil) } val Left(error) = successA1.signingSession.receiveCommitSig(params.channelParamsA, params.channelKeysA, invalidCommitSig, params.nodeParamsA.currentBlockHeight)(akka.event.NoLogging) assert(error.isInstanceOf[InvalidCommitmentSignature]) @@ -2876,8 +2879,8 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit assert(initiatorTx.buildUnsignedTx().txid == unsignedTx.txid) assert(nonInitiatorTx.buildUnsignedTx().txid == unsignedTx.txid) - val initiatorSigs = TxSignatures(channelId, unsignedTx, Seq(ScriptWitness(Seq(hex"68656c6c6f2074686572652c2074686973206973206120626974636f6e212121", hex"82012088a820add57dfe5277079d069ca4ad4893c96de91f88ffb981fdc6a2a34d5336c66aff87"))), None, None) - val nonInitiatorSigs = TxSignatures(channelId, unsignedTx, Seq(ScriptWitness(Seq(hex"304402207de9ba56bb9f641372e805782575ee840a899e61021c8b1572b3ec1d5b5950e9022069e9ba998915dae193d3c25cb89b5e64370e6a3a7755e7f31cf6d7cbc2a49f6d01", hex"034695f5b7864c580bf11f9f8cb1a94eb336f2ce9ef872d2ae1a90ee276c772484"))), None, None) + val initiatorSigs = TxSignatures(channelId, unsignedTx, Seq(ScriptWitness(Seq(hex"68656c6c6f2074686572652c2074686973206973206120626974636f6e212121", hex"82012088a820add57dfe5277079d069ca4ad4893c96de91f88ffb981fdc6a2a34d5336c66aff87"))), None) + val nonInitiatorSigs = TxSignatures(channelId, unsignedTx, Seq(ScriptWitness(Seq(hex"304402207de9ba56bb9f641372e805782575ee840a899e61021c8b1572b3ec1d5b5950e9022069e9ba998915dae193d3c25cb89b5e64370e6a3a7755e7f31cf6d7cbc2a49f6d01", hex"034695f5b7864c580bf11f9f8cb1a94eb336f2ce9ef872d2ae1a90ee276c772484"))), None) val initiatorSignedTx = FullySignedSharedTransaction(initiatorTx, initiatorSigs, nonInitiatorSigs, None) assert(initiatorSignedTx.feerate == FeeratePerKw(262 sat)) val nonInitiatorSignedTx = FullySignedSharedTransaction(nonInitiatorTx, nonInitiatorSigs, initiatorSigs, None) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptChannelStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptChannelStateSpec.scala index f364aa6f27..09b700ae3c 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptChannelStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptChannelStateSpec.scala @@ -112,7 +112,7 @@ class WaitForAcceptChannelStateSpec extends TestKitBaseClass with FixtureAnyFunS import f._ val accept = bob2alice.expectMsgType[AcceptChannel] assert(accept.channelType_opt.contains(ChannelTypes.SimpleTaprootChannelsStagingLegacy())) - assert(accept.nexLocalNonce_opt.isDefined) + assert(accept.nextLocalNonce_opt.isDefined) bob2alice.forward(alice) awaitCond(alice.stateName == WAIT_FOR_FUNDING_INTERNAL) assert(alice.stateData.asInstanceOf[DATA_WAIT_FOR_FUNDING_INTERNAL].commitmentFormat == LegacySimpleTaprootChannelCommitmentFormat) @@ -123,7 +123,7 @@ class WaitForAcceptChannelStateSpec extends TestKitBaseClass with FixtureAnyFunS import f._ val accept = bob2alice.expectMsgType[AcceptChannel] assert(accept.channelType_opt.contains(ChannelTypes.SimpleTaprootChannelsStagingLegacy())) - assert(accept.nexLocalNonce_opt.isDefined) + assert(accept.nextLocalNonce_opt.isDefined) bob2alice.forward(alice, accept.copy(tlvStream = accept.tlvStream.copy(records = accept.tlvStream.records.filterNot(_.isInstanceOf[ChannelTlv.NextLocalNonceTlv])))) alice2bob.expectMsg(Error(accept.temporaryChannelId, MissingNonce(accept.temporaryChannelId, TxId(ByteVector32.Zeroes)).getMessage)) listener.expectMsgType[ChannelAborted] diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenChannelStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenChannelStateSpec.scala index 01a31edf8e..2794d75c92 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenChannelStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenChannelStateSpec.scala @@ -114,14 +114,14 @@ class WaitForOpenChannelStateSpec extends TestKitBaseClass with FixtureAnyFunSui alice2bob.forward(bob) awaitCond(bob.stateName == WAIT_FOR_FUNDING_CREATED) assert(bob.stateData.asInstanceOf[DATA_WAIT_FOR_FUNDING_CREATED].commitmentFormat == LegacySimpleTaprootChannelCommitmentFormat) - assert(open.nexLocalNonce_opt.isDefined) + assert(open.nextLocalNonce_opt.isDefined) } test("recv OpenChannel (simple taproot channels, missing nonce)", Tag(ChannelStateTestsTags.OptionSimpleTaprootStagingLegacy)) { f => import f._ val open = alice2bob.expectMsgType[OpenChannel] assert(open.channelType_opt.contains(ChannelTypes.SimpleTaprootChannelsStagingLegacy())) - assert(open.nexLocalNonce_opt.isDefined) + assert(open.nextLocalNonce_opt.isDefined) alice2bob.forward(bob, open.copy(tlvStream = open.tlvStream.copy(records = open.tlvStream.records.filterNot(_.isInstanceOf[ChannelTlv.NextLocalNonceTlv])))) val error = bob2alice.expectMsgType[Error] assert(error == Error(open.temporaryChannelId, MissingNonce(open.temporaryChannelId, TxId(ByteVector32.Zeroes)).getMessage)) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForDualFundingSignedStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForDualFundingSignedStateSpec.scala index c78ade56d9..dfabc52c65 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForDualFundingSignedStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForDualFundingSignedStateSpec.scala @@ -22,6 +22,7 @@ import fr.acinq.eclair.TestUtils.randomTxId import fr.acinq.eclair.blockchain.{NewTransaction, SingleKeyOnChainWallet} import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{WatchFundingConfirmed, WatchPublished, WatchPublishedTriggered} import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.channel.ChannelSpendSignature.IndividualSignature import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fsm.Channel import fr.acinq.eclair.channel.fund.InteractiveTxBuilder.{FullySignedSharedTransaction, PartiallySignedSharedTransaction} @@ -242,7 +243,7 @@ class WaitForDualFundingSignedStateSpec extends TestKitBaseClass with FixtureAny val aliceCommitSig = alice2bob.expectMsgType[CommitSig] val invalidBobCommitSig = bobCommitSig.sigOrPartialSig match { - case _: ChannelSpendSignature.IndividualSignature => bobCommitSig.copy(signature = ByteVector64.Zeroes) + case _: ChannelSpendSignature.IndividualSignature => bobCommitSig.copy(signature = IndividualSignature(ByteVector64.Zeroes)) case psig: ChannelSpendSignature.PartialSignatureWithNonce => bobCommitSig.copy(tlvStream = TlvStream(CommitSigTlv.PartialSignatureWithNonceTlv(psig.copy(partialSig = psig.partialSig.reverse)))) } bob2alice.forward(alice, invalidBobCommitSig) @@ -252,7 +253,7 @@ class WaitForDualFundingSignedStateSpec extends TestKitBaseClass with FixtureAny awaitCond(alice.stateName == CLOSED) val invalidAliceCommitSig = aliceCommitSig.sigOrPartialSig match { - case _: ChannelSpendSignature.IndividualSignature => bobCommitSig.copy(signature = ByteVector64.Zeroes) + case _: ChannelSpendSignature.IndividualSignature => bobCommitSig.copy(signature = IndividualSignature(ByteVector64.Zeroes)) case psig: ChannelSpendSignature.PartialSignatureWithNonce => bobCommitSig.copy(tlvStream = TlvStream(CommitSigTlv.PartialSignatureWithNonceTlv(psig.copy(partialSig = psig.partialSig.reverse)))) } alice2bob.forward(bob, invalidAliceCommitSig) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala index 126560ae04..9f2905ee5d 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala @@ -28,6 +28,7 @@ import fr.acinq.eclair._ import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.fee._ import fr.acinq.eclair.blockchain.{CurrentBlockHeight, CurrentFeerates} +import fr.acinq.eclair.channel.ChannelSpendSignature.IndividualSignature import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fsm.Channel._ import fr.acinq.eclair.channel.publish.TxPublisher.{PublishFinalTx, PublishReplaceableTx} @@ -1249,7 +1250,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with import f._ val tx = bob.signCommitTx() // signature is invalid but it doesn't matter - bob ! CommitSig(ByteVector32.Zeroes, ByteVector64.Zeroes, Nil) + bob ! CommitSig(ByteVector32.Zeroes, IndividualSignature(ByteVector64.Zeroes), Nil) val error = bob2alice.expectMsgType[Error] assert(new String(error.data.toArray).startsWith("cannot sign when there are no changes")) awaitCond(bob.stateName == CLOSING) @@ -1266,7 +1267,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val tx = bob.signCommitTx() // actual test begins - bob ! CommitSig(ByteVector32.Zeroes, ByteVector64.Zeroes, Nil) + bob ! CommitSig(ByteVector32.Zeroes, IndividualSignature(ByteVector64.Zeroes), Nil) val error = bob2alice.expectMsgType[Error] assert(new String(error.data.toArray).startsWith("invalid commitment signature")) awaitCond(bob.stateName == CLOSING) @@ -1344,7 +1345,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val commitSig = alice2bob.expectMsgType[CommitSig] // actual test begins - val badCommitSig = commitSig.copy(htlcSignatures = commitSig.signature :: Nil) + val badCommitSig = commitSig.copy(htlcSignatures = commitSig.signature.sig :: Nil) bob ! badCommitSig val error = bob2alice.expectMsgType[Error] assert(new String(error.data.toArray).startsWith("invalid htlc signature")) @@ -1475,7 +1476,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // actual test begins val revokeAndAck = bob2alice.expectMsgType[RevokeAndAck] - val revokeAndAckWithMissingNonce = revokeAndAck.copy(tlvStream = revokeAndAck.tlvStream.copy(records = revokeAndAck.tlvStream.records.filterNot(tlv => tlv.isInstanceOf[RevokeAndAckTlv.NextLocalNoncesTlv] || tlv.isInstanceOf[RevokeAndAckTlv.NextLocalNonceTlv]))) + val revokeAndAckWithMissingNonce = revokeAndAck.copy(tlvStream = revokeAndAck.tlvStream.copy(records = revokeAndAck.tlvStream.records.filterNot(tlv => tlv.isInstanceOf[RevokeAndAckTlv.NextLocalNoncesTlv]))) alice ! revokeAndAckWithMissingNonce alice2bob.expectMsgType[Error] awaitCond(alice.stateName == CLOSING) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/f/ShutdownStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/f/ShutdownStateSpec.scala index d083981cd3..1b8581c7e5 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/f/ShutdownStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/f/ShutdownStateSpec.scala @@ -24,6 +24,7 @@ import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, Crypto, Satoshi import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.fee.{FeeratePerKw, FeeratesPerKw} import fr.acinq.eclair.blockchain.{CurrentBlockHeight, CurrentFeerates} +import fr.acinq.eclair.channel.ChannelSpendSignature.IndividualSignature import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.publish.TxPublisher.{PublishFinalTx, PublishTx} import fr.acinq.eclair.channel.states.ChannelStateTestsBase.PimpTestFSM @@ -414,7 +415,7 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit import f._ val tx = bob.signCommitTx() // signature is invalid but it doesn't matter - bob ! CommitSig(ByteVector32.Zeroes, ByteVector64.Zeroes, Nil) + bob ! CommitSig(ByteVector32.Zeroes, IndividualSignature(ByteVector64.Zeroes), Nil) bob2alice.expectMsgType[Error] awaitCond(bob.stateName == CLOSING) assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == tx.txid) // commit tx @@ -425,7 +426,7 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit test("recv CommitSig (invalid signature)") { f => import f._ val tx = bob.signCommitTx() - bob ! CommitSig(ByteVector32.Zeroes, ByteVector64.Zeroes, Nil) + bob ! CommitSig(ByteVector32.Zeroes, IndividualSignature(ByteVector64.Zeroes), Nil) bob2alice.expectMsgType[Error] awaitCond(bob.stateName == CLOSING) assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == tx.txid) // commit tx diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerConnectionSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerConnectionSpec.scala index dcefce23aa..9bf2b676f8 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerConnectionSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerConnectionSpec.scala @@ -24,6 +24,7 @@ import fr.acinq.eclair.FeatureSupport.{Mandatory, Optional} import fr.acinq.eclair.Features._ import fr.acinq.eclair.TestConstants._ import fr.acinq.eclair.TestUtils.randomTxId +import fr.acinq.eclair.channel.ChannelSpendSignature.IndividualSignature import fr.acinq.eclair.crypto.TransportHandler import fr.acinq.eclair.io.Peer.ConnectionDown import fr.acinq.eclair.message.OnionMessages.{Recipient, buildMessage} @@ -340,9 +341,9 @@ class PeerConnectionSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wi connect(nodeParams, remoteNodeId, switchboard, router, connection, transport, peerConnection, peer) val channelId = randomBytes32() val commitSigs = Seq( - CommitSig(channelId, randomBytes64(), Nil), - CommitSig(channelId, randomBytes64(), Nil), - CommitSig(channelId, randomBytes64(), Nil), + CommitSig(channelId, IndividualSignature(randomBytes64()), Nil), + CommitSig(channelId, IndividualSignature(randomBytes64()), Nil), + CommitSig(channelId, IndividualSignature(randomBytes64()), Nil), ) probe.send(peerConnection, CommitSigBatch(commitSigs)) commitSigs.foreach(commitSig => transport.expectMsg(commitSig)) @@ -356,8 +357,8 @@ class PeerConnectionSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wi // We receive a batch of commit_sig messages from a first channel. val channelId1 = randomBytes32() val commitSigs1 = Seq( - CommitSig(channelId1, randomBytes64(), Nil, TlvStream(CommitSigTlv.BatchTlv(2))), - CommitSig(channelId1, randomBytes64(), Nil, TlvStream(CommitSigTlv.BatchTlv(2))), + CommitSig(channelId1, IndividualSignature(randomBytes64()), Nil, TlvStream(CommitSigTlv.BatchTlv(2))), + CommitSig(channelId1, IndividualSignature(randomBytes64()), Nil, TlvStream(CommitSigTlv.BatchTlv(2))), ) transport.send(peerConnection, commitSigs1.head) transport.expectMsg(TransportHandler.ReadAck(commitSigs1.head)) @@ -369,9 +370,9 @@ class PeerConnectionSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wi // We receive a batch of commit_sig messages from a second channel. val channelId2 = randomBytes32() val commitSigs2 = Seq( - CommitSig(channelId2, randomBytes64(), Nil, TlvStream(CommitSigTlv.BatchTlv(3))), - CommitSig(channelId2, randomBytes64(), Nil, TlvStream(CommitSigTlv.BatchTlv(3))), - CommitSig(channelId2, randomBytes64(), Nil, TlvStream(CommitSigTlv.BatchTlv(3))), + CommitSig(channelId2, IndividualSignature(randomBytes64()), Nil, TlvStream(CommitSigTlv.BatchTlv(3))), + CommitSig(channelId2, IndividualSignature(randomBytes64()), Nil, TlvStream(CommitSigTlv.BatchTlv(3))), + CommitSig(channelId2, IndividualSignature(randomBytes64()), Nil, TlvStream(CommitSigTlv.BatchTlv(3))), ) commitSigs2.dropRight(1).foreach(commitSig => { transport.send(peerConnection, commitSig) @@ -384,8 +385,8 @@ class PeerConnectionSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wi // We receive another batch of commit_sig messages from the first channel, with unrelated messages in the batch. val commitSigs3 = Seq( - CommitSig(channelId1, randomBytes64(), Nil, TlvStream(CommitSigTlv.BatchTlv(2))), - CommitSig(channelId1, randomBytes64(), Nil, TlvStream(CommitSigTlv.BatchTlv(2))), + CommitSig(channelId1, IndividualSignature(randomBytes64()), Nil, TlvStream(CommitSigTlv.BatchTlv(2))), + CommitSig(channelId1, IndividualSignature(randomBytes64()), Nil, TlvStream(CommitSigTlv.BatchTlv(2))), ) transport.send(peerConnection, commitSigs3.head) transport.expectMsg(TransportHandler.ReadAck(commitSigs3.head)) @@ -405,9 +406,9 @@ class PeerConnectionSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wi // We start receiving a batch of commit_sig messages from the first channel, interleaved with a batch from the second // channel, which is not supported. val commitSigs4 = Seq( - CommitSig(channelId1, randomBytes64(), Nil, TlvStream(CommitSigTlv.BatchTlv(2))), - CommitSig(channelId2, randomBytes64(), Nil, TlvStream(CommitSigTlv.BatchTlv(2))), - CommitSig(channelId2, randomBytes64(), Nil, TlvStream(CommitSigTlv.BatchTlv(2))), + CommitSig(channelId1, IndividualSignature(randomBytes64()), Nil, TlvStream(CommitSigTlv.BatchTlv(2))), + CommitSig(channelId2, IndividualSignature(randomBytes64()), Nil, TlvStream(CommitSigTlv.BatchTlv(2))), + CommitSig(channelId2, IndividualSignature(randomBytes64()), Nil, TlvStream(CommitSigTlv.BatchTlv(2))), ) transport.send(peerConnection, commitSigs4.head) transport.expectMsg(TransportHandler.ReadAck(commitSigs4.head)) @@ -420,7 +421,7 @@ class PeerConnectionSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wi peer.expectMsg(CommitSigBatch(commitSigs4.tail)) // We receive a batch that exceeds our threshold: we process them individually. - val invalidCommitSigs = (0 until 30).map(_ => CommitSig(channelId2, randomBytes64(), Nil, TlvStream(CommitSigTlv.BatchTlv(30)))) + val invalidCommitSigs = (0 until 30).map(_ => CommitSig(channelId2, IndividualSignature(randomBytes64()), Nil, TlvStream(CommitSigTlv.BatchTlv(30)))) invalidCommitSigs.foreach(commitSig => { transport.send(peerConnection, commitSig) transport.expectMsg(TransportHandler.ReadAck(commitSig)) 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 150b2c7aa4..31d202a182 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 @@ -17,6 +17,7 @@ package fr.acinq.eclair.wire.protocol import com.google.common.base.Charsets +import fr.acinq.bitcoin.crypto.musig2.IndividualNonce import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} import fr.acinq.bitcoin.scalacompat.{Block, BlockHash, ByteVector32, ByteVector64, OutPoint, SatoshiLong, Script, ScriptWitness, Transaction, TxHash, TxId} import fr.acinq.eclair.FeatureSupport.Optional @@ -24,6 +25,7 @@ import fr.acinq.eclair.Features.DataLossProtect import fr.acinq.eclair.TestUtils.randomTxId import fr.acinq.eclair._ import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.channel.ChannelSpendSignature.{IndividualSignature, PartialSignatureWithNonce} import fr.acinq.eclair.channel.{ChannelFlags, ChannelTypes} import fr.acinq.eclair.json.JsonSerializers import fr.acinq.eclair.reputation.Reputation @@ -140,10 +142,14 @@ class LightningMessageCodecsSpec extends AnyFunSuite { test("nonreg generic tlv") { val channelId = randomBytes32() + val partialSig = randomBytes32() val signature = randomBytes64() val key = randomKey() val point = randomKey().publicKey val txId = randomTxId() + val nextTxId = randomTxId() + val nonce = new IndividualNonce(randomBytes(66).toArray) + val nextNonce = new IndividualNonce(randomBytes(66).toArray) val randomData = randomBytes(42) val tlvTag = UInt64(hex"47010000") @@ -156,18 +162,24 @@ class LightningMessageCodecsSpec extends AnyFunSuite { hex"0088" ++ channelId ++ hex"0001020304050607 0809aabbccddeeff" ++ key.value ++ point.value ++ hex"00 20" ++ txId.value.reverse -> ChannelReestablish(channelId, 0x01020304050607L, 0x0809aabbccddeeffL, key, point, TlvStream(ChannelReestablishTlv.NextFundingTlv(txId))), hex"0088" ++ channelId ++ hex"0001020304050607 0809aabbccddeeff" ++ key.value ++ point.value ++ hex"01 20" ++ txId.value.reverse -> ChannelReestablish(channelId, 0x01020304050607L, 0x0809aabbccddeeffL, key, point, TlvStream(ChannelReestablishTlv.YourLastFundingLockedTlv(txId))), hex"0088" ++ channelId ++ hex"0001020304050607 0809aabbccddeeff" ++ key.value ++ point.value ++ hex"03 20" ++ txId.value.reverse -> ChannelReestablish(channelId, 0x01020304050607L, 0x0809aabbccddeeffL, key, point, TlvStream(ChannelReestablishTlv.MyCurrentFundingLockedTlv(txId))), + hex"0088" ++ channelId ++ hex"0001020304050607 0809aabbccddeeff" ++ key.value ++ point.value ++ hex"06 42" ++ ByteVector(nonce.toByteArray) -> ChannelReestablish(channelId, 0x01020304050607L, 0x0809aabbccddeeffL, key, point, TlvStream(ChannelReestablishTlv.CurrentCommitNonceTlv(nonce))), + hex"0088" ++ channelId ++ hex"0001020304050607 0809aabbccddeeff" ++ key.value ++ point.value ++ hex"04 c4" ++ txId.value.reverse ++ ByteVector(nonce.toByteArray) ++ nextTxId.value.reverse ++ ByteVector(nextNonce.toByteArray) -> ChannelReestablish(channelId, 0x01020304050607L, 0x0809aabbccddeeffL, key, point, TlvStream(ChannelReestablishTlv.NextLocalNoncesTlv(Seq(txId -> nonce, nextTxId -> nextNonce)))), hex"0088" ++ channelId ++ hex"0001020304050607 0809aabbccddeeff" ++ key.value ++ point.value ++ hex"fe47010000 00" -> ChannelReestablish(channelId, 0x01020304050607L, 0x0809aabbccddeeffL, key, point, TlvStream[ChannelReestablishTlv](Set.empty[ChannelReestablishTlv], Set(GenericTlv(tlvTag, ByteVector.empty)))), hex"0088" ++ channelId ++ hex"0001020304050607 0809aabbccddeeff" ++ key.value ++ point.value ++ hex"fe47010000 07 bbbbbbbbbbbbbb" -> ChannelReestablish(channelId, 0x01020304050607L, 0x0809aabbccddeeffL, key, point, TlvStream[ChannelReestablishTlv](Set.empty[ChannelReestablishTlv], Set(GenericTlv(tlvTag, hex"bbbbbbbbbbbbbb")))), - hex"0084" ++ channelId ++ signature ++ hex"0000" -> CommitSig(channelId, signature, Nil), - hex"0084" ++ channelId ++ signature ++ hex"0000 fe47010000 00" -> CommitSig(channelId, signature, Nil, TlvStream[CommitSigTlv](Set.empty[CommitSigTlv], Set(GenericTlv(tlvTag, ByteVector.empty)))), - hex"0084" ++ channelId ++ signature ++ hex"0000 fe47010000 07 cccccccccccccc" -> CommitSig(channelId, signature, Nil, TlvStream[CommitSigTlv](Set.empty[CommitSigTlv], Set(GenericTlv(tlvTag, hex"cccccccccccccc")))), + hex"0084" ++ channelId ++ signature ++ hex"0000" -> CommitSig(channelId, IndividualSignature(signature), Nil), + hex"0084" ++ channelId ++ ByteVector64.Zeroes ++ hex"0000" ++ hex"02 62" ++ partialSig ++ ByteVector(nonce.toByteArray) -> CommitSig(channelId, PartialSignatureWithNonce(partialSig, nonce), Nil), + hex"0084" ++ channelId ++ signature ++ hex"0000 fe47010000 00" -> CommitSig(channelId, IndividualSignature(signature), Nil, TlvStream[CommitSigTlv](Set.empty[CommitSigTlv], Set(GenericTlv(tlvTag, ByteVector.empty)))), + hex"0084" ++ channelId ++ signature ++ hex"0000 fe47010000 07 cccccccccccccc" -> CommitSig(channelId, IndividualSignature(signature), Nil, TlvStream[CommitSigTlv](Set.empty[CommitSigTlv], Set(GenericTlv(tlvTag, hex"cccccccccccccc")))), hex"0085" ++ channelId ++ key.value ++ point.value -> RevokeAndAck(channelId, key, point), + hex"0085" ++ channelId ++ key.value ++ point.value ++ hex"06 62" ++ txId.value.reverse ++ ByteVector(nonce.toByteArray) -> RevokeAndAck(channelId, key, point, Seq(txId -> nonce)), + hex"0085" ++ channelId ++ key.value ++ point.value ++ hex"06 c4" ++ txId.value.reverse ++ ByteVector(nonce.toByteArray) ++ nextTxId.value.reverse ++ ByteVector(nextNonce.toByteArray) -> RevokeAndAck(channelId, key, point, Seq(txId -> nonce, nextTxId -> nextNonce)), hex"0085" ++ channelId ++ key.value ++ point.value ++ hex" fe47010000 00" -> RevokeAndAck(channelId, key, point, TlvStream[RevokeAndAckTlv](Set.empty[RevokeAndAckTlv], Set(GenericTlv(tlvTag, ByteVector.empty)))), hex"0085" ++ channelId ++ key.value ++ point.value ++ hex" fe47010000 07 cccccccccccccc" -> RevokeAndAck(channelId, key, point, TlvStream[RevokeAndAckTlv](Set.empty[RevokeAndAckTlv], Set(GenericTlv(tlvTag, hex"cccccccccccccc")))), hex"0026" ++ channelId ++ hex"002a" ++ randomData -> Shutdown(channelId, randomData), + hex"0026" ++ channelId ++ hex"002a" ++ randomData ++ hex"08 42" ++ ByteVector(nonce.toByteArray) -> Shutdown(channelId, randomData, nonce), hex"0026" ++ channelId ++ hex"002a" ++ randomData ++ hex"fe47010000 00" -> Shutdown(channelId, randomData, TlvStream[ShutdownTlv](Set.empty[ShutdownTlv], Set(GenericTlv(tlvTag, ByteVector.empty)))), hex"0026" ++ channelId ++ hex"002a" ++ randomData ++ hex"fe47010000 07 cccccccccccccc" -> Shutdown(channelId, randomData, TlvStream[ShutdownTlv](Set.empty[ShutdownTlv], Set(GenericTlv(tlvTag, hex"cccccccccccccc")))), @@ -195,12 +207,16 @@ class LightningMessageCodecsSpec extends AnyFunSuite { val channelId1 = ByteVector32(hex"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") val channelId2 = ByteVector32(hex"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") val signature = ByteVector64(hex"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + val partialSig = ByteVector32(hex"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") // This is a random mainnet transaction. val txBin1 = hex"020000000001014ade359c5deb7c1cde2e94f401854658f97d7fa31c17ce9a831db253120a0a410100000017160014eb9a5bd79194a23d19d6ec473c768fb74f9ed32cffffffff021ca408000000000017a914946118f24bb7b37d5e9e39579e4a411e70f5b6a08763e703000000000017a9143638b2602d11f934c04abc6adb1494f69d1f14af8702473044022059ddd943b399211e4266a349f26b3289979e29f9b067792c6cfa8cc5ae25f44602204d627a5a5b603d0562e7969011fb3d64908af90a3ec7c876eaa9baf61e1958af012102f5188df1da92ed818581c29778047800ed6635788aa09d9469f7d17628f7323300000000" val tx1 = Transaction.read(txBin1.toArray) // This is random, longer mainnet transaction. val txBin2 = hex"0200000000010142180a8812fc79a3da7fb2471eff3e22d7faee990604c2ba7f2fc8dfb15b550a0200000000feffffff030f241800000000001976a9146774040642a78ca3b8b395e70f8391b21ec026fc88ac4a155801000000001600148d2e0b57adcb8869e603fd35b5179caf053361253b1d010000000000160014e032f4f4b9f8611df0d30a20648c190c263bbc33024730440220506005aa347f5b698542cafcb4f1a10250aeb52a609d6fd67ef68f9c1a5d954302206b9bb844343f4012bccd9d08a0f5430afb9549555a3252e499be7df97aae477a012103976d6b3eea3de4b056cd88cdfd50a22daf121e0fb5c6e45ba0f40e1effbd275a00000000" val tx2 = Transaction.read(txBin2.toArray) + val nonce = new IndividualNonce("2062534ccb3be5a8997843f3b6bc530a94cbc60eceb538674ceedd62d8be07f2dfa5df6acf3ded7444268d56925bb2c33afe71a55f4fa88f3985451a681415930f6b") + val nextNonce = new IndividualNonce("b218b34786408f0a1aee2b35a0e860aa234b8013d1c385d1fcb4583fc4472bedfdd69a53c71006ec9f8b33724b719a50aa137814f4d0c00caff4e1da0d9856a957e7") + val fundingNonce = new IndividualNonce("a49ff67b08c720b993c946556cde1be1c3b664bc847c4792135dfd6ef0986e00e9871808c6620b0420567dad525b27431453d4434fd326f8ac56496639b72326eb5d") val fundingRate = LiquidityAds.FundingRate(25_000 sat, 250_000 sat, 750, 150, 50 sat, 500 sat) val testCases = Seq( TxAddInput(channelId1, UInt64(561), Some(tx1), 1, 5) -> hex"0042 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000231 00f7 020000000001014ade359c5deb7c1cde2e94f401854658f97d7fa31c17ce9a831db253120a0a410100000017160014eb9a5bd79194a23d19d6ec473c768fb74f9ed32cffffffff021ca408000000000017a914946118f24bb7b37d5e9e39579e4a411e70f5b6a08763e703000000000017a9143638b2602d11f934c04abc6adb1494f69d1f14af8702473044022059ddd943b399211e4266a349f26b3289979e29f9b067792c6cfa8cc5ae25f44602204d627a5a5b603d0562e7969011fb3d64908af90a3ec7c876eaa9baf61e1958af012102f5188df1da92ed818581c29778047800ed6635788aa09d9469f7d17628f7323300000000 00000001 00000005", @@ -212,10 +228,13 @@ class LightningMessageCodecsSpec extends AnyFunSuite { TxRemoveInput(channelId2, UInt64(561)) -> hex"0044 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 0000000000000231", TxRemoveOutput(channelId1, UInt64(1)) -> hex"0045 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000001", TxComplete(channelId1) -> hex"0046 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + TxComplete(channelId1, nonce, nextNonce, None) -> hex"0046 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 04 84 2062534ccb3be5a8997843f3b6bc530a94cbc60eceb538674ceedd62d8be07f2dfa5df6acf3ded7444268d56925bb2c33afe71a55f4fa88f3985451a681415930f6b b218b34786408f0a1aee2b35a0e860aa234b8013d1c385d1fcb4583fc4472bedfdd69a53c71006ec9f8b33724b719a50aa137814f4d0c00caff4e1da0d9856a957e7", + TxComplete(channelId1, nonce, nextNonce, Some(fundingNonce)) -> hex"0046 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 04 c6 2062534ccb3be5a8997843f3b6bc530a94cbc60eceb538674ceedd62d8be07f2dfa5df6acf3ded7444268d56925bb2c33afe71a55f4fa88f3985451a681415930f6b b218b34786408f0a1aee2b35a0e860aa234b8013d1c385d1fcb4583fc4472bedfdd69a53c71006ec9f8b33724b719a50aa137814f4d0c00caff4e1da0d9856a957e7 a49ff67b08c720b993c946556cde1be1c3b664bc847c4792135dfd6ef0986e00e9871808c6620b0420567dad525b27431453d4434fd326f8ac56496639b72326eb5d", TxComplete(channelId1, TlvStream(Set.empty[TxCompleteTlv], Set(GenericTlv(UInt64(231), hex"deadbeef"), GenericTlv(UInt64(507), hex"")))) -> hex"0046 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa e704deadbeef fd01fb00", - TxSignatures(channelId1, tx2, Seq(ScriptWitness(Seq(hex"68656c6c6f2074686572652c2074686973206973206120626974636f6e212121", hex"82012088a820add57dfe5277079d069ca4ad4893c96de91f88ffb981fdc6a2a34d5336c66aff87")), ScriptWitness(Seq(hex"304402207de9ba56bb9f641372e805782575ee840a899e61021c8b1572b3ec1d5b5950e9022069e9ba998915dae193d3c25cb89b5e64370e6a3a7755e7f31cf6d7cbc2a49f6d01", hex"034695f5b7864c580bf11f9f8cb1a94eb336f2ce9ef872d2ae1a90ee276c772484"))), None, None) -> hex"0047 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa fc7aa8845f192959202c1b7ff704e7cbddded463c05e844676a94ccb4bed69f1 0002 004a 022068656c6c6f2074686572652c2074686973206973206120626974636f6e2121212782012088a820add57dfe5277079d069ca4ad4893c96de91f88ffb981fdc6a2a34d5336c66aff87 006b 0247304402207de9ba56bb9f641372e805782575ee840a899e61021c8b1572b3ec1d5b5950e9022069e9ba998915dae193d3c25cb89b5e64370e6a3a7755e7f31cf6d7cbc2a49f6d0121034695f5b7864c580bf11f9f8cb1a94eb336f2ce9ef872d2ae1a90ee276c772484", - TxSignatures(channelId2, tx1, Nil, None, None) -> hex"0047 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 1f2ec025a33e39ef8e177afcdc1adc855bf128dc906182255aeb64efa825f106 0000", - TxSignatures(channelId2, tx1, Nil, Some(signature), None) -> hex"0047 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 1f2ec025a33e39ef8e177afcdc1adc855bf128dc906182255aeb64efa825f106 0000 fd0259 40 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + TxSignatures(channelId1, tx2, Seq(ScriptWitness(Seq(hex"68656c6c6f2074686572652c2074686973206973206120626974636f6e212121", hex"82012088a820add57dfe5277079d069ca4ad4893c96de91f88ffb981fdc6a2a34d5336c66aff87")), ScriptWitness(Seq(hex"304402207de9ba56bb9f641372e805782575ee840a899e61021c8b1572b3ec1d5b5950e9022069e9ba998915dae193d3c25cb89b5e64370e6a3a7755e7f31cf6d7cbc2a49f6d01", hex"034695f5b7864c580bf11f9f8cb1a94eb336f2ce9ef872d2ae1a90ee276c772484"))), None) -> hex"0047 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa fc7aa8845f192959202c1b7ff704e7cbddded463c05e844676a94ccb4bed69f1 0002 004a 022068656c6c6f2074686572652c2074686973206973206120626974636f6e2121212782012088a820add57dfe5277079d069ca4ad4893c96de91f88ffb981fdc6a2a34d5336c66aff87 006b 0247304402207de9ba56bb9f641372e805782575ee840a899e61021c8b1572b3ec1d5b5950e9022069e9ba998915dae193d3c25cb89b5e64370e6a3a7755e7f31cf6d7cbc2a49f6d0121034695f5b7864c580bf11f9f8cb1a94eb336f2ce9ef872d2ae1a90ee276c772484", + TxSignatures(channelId2, tx1, Nil, None) -> hex"0047 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 1f2ec025a33e39ef8e177afcdc1adc855bf128dc906182255aeb64efa825f106 0000", + TxSignatures(channelId2, tx1, Nil, Some(IndividualSignature(signature))) -> hex"0047 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 1f2ec025a33e39ef8e177afcdc1adc855bf128dc906182255aeb64efa825f106 0000 fd0259 40 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + TxSignatures(channelId2, tx1, Nil, Some(PartialSignatureWithNonce(partialSig, fundingNonce))) -> hex"0047 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 1f2ec025a33e39ef8e177afcdc1adc855bf128dc906182255aeb64efa825f106 0000 02 62 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb a49ff67b08c720b993c946556cde1be1c3b664bc847c4792135dfd6ef0986e00e9871808c6620b0420567dad525b27431453d4434fd326f8ac56496639b72326eb5d", TxInitRbf(channelId1, 8388607, FeeratePerKw(4000 sat)) -> hex"0048 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 007fffff 00000fa0", TxInitRbf(channelId1, 0, FeeratePerKw(4000 sat), 1_500_000 sat, requireConfirmedInputs = true, None) -> hex"0048 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000 00000fa0 0008000000000016e360 0200", TxInitRbf(channelId1, 0, FeeratePerKw(4000 sat), 0 sat, requireConfirmedInputs = false, None) -> hex"0048 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 00000000 00000fa0 00080000000000000000", @@ -240,6 +259,7 @@ class LightningMessageCodecsSpec extends AnyFunSuite { test("encode/decode open_channel") { val defaultOpen = OpenChannel(BlockHash(ByteVector32.Zeroes), ByteVector32.Zeroes, 1 sat, 1 msat, 1 sat, UInt64(1), 1 sat, 1 msat, FeeratePerKw(1 sat), CltvExpiryDelta(1), 1, publicKey(1), point(2), point(3), point(4), point(5), point(6), ChannelFlags(announceChannel = false)) + val nonce = new IndividualNonce("2062534ccb3be5a8997843f3b6bc530a94cbc60eceb538674ceedd62d8be07f2dfa5df6acf3ded7444268d56925bb2c33afe71a55f4fa88f3985451a681415930f6b") // Legacy encoding that omits the upfront_shutdown_script and trailing tlv stream. // To allow extending all messages with TLV streams, the upfront_shutdown_script was moved to a TLV stream extension // in https://github.com/lightningnetwork/lightning-rfc/pull/714 and made mandatory when including a TLV stream. @@ -282,6 +302,8 @@ class LightningMessageCodecsSpec extends AnyFunSuite { defaultEncoded ++ hex"0000" ++ hex"0107 04400000001000" -> defaultOpen.copy(tlvStream = TlvStream(ChannelTlv.UpfrontShutdownScriptTlv(ByteVector.empty), ChannelTlv.ChannelTypeTlv(ChannelTypes.StaticRemoteKey(scidAlias = true, zeroConf = true)))), defaultEncoded ++ hex"0000" ++ hex"0107 04400000101000" -> defaultOpen.copy(tlvStream = TlvStream(ChannelTlv.UpfrontShutdownScriptTlv(ByteVector.empty), ChannelTlv.ChannelTypeTlv(ChannelTypes.AnchorOutputs(scidAlias = true, zeroConf = true)))), defaultEncoded ++ hex"0000" ++ hex"0107 04400000401000" -> defaultOpen.copy(tlvStream = TlvStream(ChannelTlv.UpfrontShutdownScriptTlv(ByteVector.empty), ChannelTlv.ChannelTypeTlv(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(scidAlias = true, zeroConf = true)))), + // taproot channel type + nonce + defaultEncoded ++ hex"0000" ++ hex"01 17 1000000000000000000000000000000000400000000000" ++ hex"04 42 2062534ccb3be5a8997843f3b6bc530a94cbc60eceb538674ceedd62d8be07f2dfa5df6acf3ded7444268d56925bb2c33afe71a55f4fa88f3985451a681415930f6b" -> defaultOpen.copy(tlvStream = TlvStream(ChannelTlv.UpfrontShutdownScriptTlv(ByteVector.empty), ChannelTlv.ChannelTypeTlv(ChannelTypes.SimpleTaprootChannelsStaging(scidAlias = true)), ChannelTlv.NextLocalNonceTlv(nonce))) ) for ((encoded, expected) <- testCases) { @@ -344,6 +366,7 @@ class LightningMessageCodecsSpec extends AnyFunSuite { test("encode/decode accept_channel") { val defaultAccept = AcceptChannel(ByteVector32.Zeroes, 1 sat, UInt64(1), 1 sat, 1 msat, 1, CltvExpiryDelta(1), 1, publicKey(1), point(2), point(3), point(4), point(5), point(6)) + val nonce = new IndividualNonce("2062534ccb3be5a8997843f3b6bc530a94cbc60eceb538674ceedd62d8be07f2dfa5df6acf3ded7444268d56925bb2c33afe71a55f4fa88f3985451a681415930f6b") // Legacy encoding that omits the upfront_shutdown_script and trailing tlv stream. // To allow extending all messages with TLV streams, the upfront_shutdown_script was moved to a TLV stream extension // in https://github.com/lightningnetwork/lightning-rfc/pull/714 and made mandatory when including a TLV stream. @@ -354,10 +377,11 @@ class LightningMessageCodecsSpec extends AnyFunSuite { defaultEncoded ++ hex"0000" -> defaultAccept.copy(tlvStream = TlvStream(ChannelTlv.UpfrontShutdownScriptTlv(ByteVector.empty))), // empty upfront_shutdown_script defaultEncoded ++ hex"0000" ++ hex"0100" -> defaultAccept.copy(tlvStream = TlvStream(ChannelTlv.UpfrontShutdownScriptTlv(ByteVector.empty), ChannelTlv.ChannelTypeTlv(ChannelTypes.Standard()))), // empty upfront_shutdown_script with channel type defaultEncoded ++ hex"0004 01abcdef" -> defaultAccept.copy(tlvStream = TlvStream(ChannelTlv.UpfrontShutdownScriptTlv(hex"01abcdef"))), // non-empty upfront_shutdown_script + defaultEncoded ++ hex"0000" ++ hex"01 17 1000000000000000000000000000000000000000000000" ++ hex"04 42 2062534ccb3be5a8997843f3b6bc530a94cbc60eceb538674ceedd62d8be07f2dfa5df6acf3ded7444268d56925bb2c33afe71a55f4fa88f3985451a681415930f6b" -> defaultAccept.copy(tlvStream = TlvStream(ChannelTlv.UpfrontShutdownScriptTlv(ByteVector.empty), ChannelTlv.ChannelTypeTlv(ChannelTypes.SimpleTaprootChannelsStaging()), ChannelTlv.NextLocalNonceTlv(nonce))), // empty upfront_shutdown_script with taproot channel type and nonce defaultEncoded ++ hex"0004 01abcdef" ++ hex"01021000" -> defaultAccept.copy(tlvStream = TlvStream(ChannelTlv.UpfrontShutdownScriptTlv(hex"01abcdef"), ChannelTlv.ChannelTypeTlv(ChannelTypes.StaticRemoteKey()))), // non-empty upfront_shutdown_script with channel type defaultEncoded ++ hex"0000 0302002a 050102" -> defaultAccept.copy(tlvStream = TlvStream(Set[AcceptChannelTlv](ChannelTlv.UpfrontShutdownScriptTlv(ByteVector.empty)), Set(GenericTlv(UInt64(3), hex"002a"), GenericTlv(UInt64(5), hex"02")))), // empty upfront_shutdown_script + unknown odd tlv records defaultEncoded ++ hex"0002 1234 0303010203" -> defaultAccept.copy(tlvStream = TlvStream(Set[AcceptChannelTlv](ChannelTlv.UpfrontShutdownScriptTlv(hex"1234")), Set(GenericTlv(UInt64(3), hex"010203")))), // non-empty upfront_shutdown_script + unknown odd tlv records - defaultEncoded ++ hex"0303010203 05020123" -> defaultAccept.copy(tlvStream = TlvStream(Set.empty[AcceptChannelTlv], Set(GenericTlv(UInt64(3), hex"010203"), GenericTlv(UInt64(5), hex"0123")))) // no upfront_shutdown_script + unknown odd tlv records + defaultEncoded ++ hex"0303010203 05020123" -> defaultAccept.copy(tlvStream = TlvStream(Set.empty[AcceptChannelTlv], Set(GenericTlv(UInt64(3), hex"010203"), GenericTlv(UInt64(5), hex"0123")))), // no upfront_shutdown_script + unknown odd tlv records ) for ((encoded, expected) <- testCases) { @@ -525,8 +549,14 @@ class LightningMessageCodecsSpec extends AnyFunSuite { test("encode/decode closing messages") { val channelId = ByteVector32(hex"58a00a6f14e69a2e97b18cf76f755c8551fea9947cf7b6ece9d641013eba5f86") val sig1 = ByteVector64(hex"01010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101") + val partialSig1 = ByteVector32(hex"0101010101010101010101010101010101010101010101010101010101010101") + val nonce1 = new IndividualNonce("52682593fd0783ea60657ed2d118e8f958c4a7a198237749b6729eccf963be1bc559531ec4b83bcfc42009cd08f7e95747146cec2fd09571b3fa76656e3012a4c97a") val sig2 = ByteVector64(hex"02020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202") + val partialSig2 = ByteVector32(hex"0202020202020202020202020202020202020202020202020202020202020202") + val nonce2 = new IndividualNonce("585b2fe8ca7a969bbda11ee9cbc95386abfddcc901967f84da4011c2a7cb5ada1dae51bdcd93a8b2933fcec7b2cda5a3f43ea2d0a29eb126bd329d4735d5389fe703") val sig3 = ByteVector64(hex"03030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303") + val partialSig3 = ByteVector32(hex"0303030303030303030303030303030303030303030303030303030303030303") + val nonce3 = new IndividualNonce("19bed0825ceb5acf504cddea72e37a75505290a22850c183725963edfe2dfb9f26e27180b210c05635987b80b3de3b7d01732653565b9f25ec23f7aff26122e00bff") val closerScript = hex"deadbeef" val closeeScript = hex"d43db3ef1234" val testCases = Seq( @@ -535,11 +565,15 @@ class LightningMessageCodecsSpec extends AnyFunSuite { hex"0028 58a00a6f14e69a2e97b18cf76f755c8551fea9947cf7b6ece9d641013eba5f86 0004deadbeef 0006d43db3ef1234 0000000000000451 00000000 034001010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101" -> ClosingComplete(channelId, closerScript, closeeScript, 1105 sat, 0, TlvStream(ClosingTlv.CloserAndCloseeOutputs(sig1))), hex"0028 58a00a6f14e69a2e97b18cf76f755c8551fea9947cf7b6ece9d641013eba5f86 0004deadbeef 0006d43db3ef1234 0000000000000451 00000000 014001010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101 034002020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202" -> ClosingComplete(channelId, closerScript, closeeScript, 1105 sat, 0, TlvStream(ClosingTlv.CloserOutputOnly(sig1), ClosingTlv.CloserAndCloseeOutputs(sig2))), hex"0028 58a00a6f14e69a2e97b18cf76f755c8551fea9947cf7b6ece9d641013eba5f86 0004deadbeef 0006d43db3ef1234 0000000000000451 00000000 014001010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101 024002020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202 034003030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303" -> ClosingComplete(channelId, closerScript, closeeScript, 1105 sat, 0, TlvStream(ClosingTlv.CloserOutputOnly(sig1), ClosingTlv.CloseeOutputOnly(sig2), ClosingTlv.CloserAndCloseeOutputs(sig3))), + hex"0028 58a00a6f14e69a2e97b18cf76f755c8551fea9947cf7b6ece9d641013eba5f86 0004deadbeef 0006d43db3ef1234 0000000000000451 00000000 05620202020202020202020202020202020202020202020202020202020202020202585b2fe8ca7a969bbda11ee9cbc95386abfddcc901967f84da4011c2a7cb5ada1dae51bdcd93a8b2933fcec7b2cda5a3f43ea2d0a29eb126bd329d4735d5389fe703" -> ClosingComplete(channelId, closerScript, closeeScript, 1105 sat, 0, TlvStream(ClosingCompleteTlv.CloseeOutputOnlyPartialSignature(PartialSignatureWithNonce(partialSig2, nonce2)))), + hex"0028 58a00a6f14e69a2e97b18cf76f755c8551fea9947cf7b6ece9d641013eba5f86 0004deadbeef 0006d43db3ef1234 0000000000000451 00000000 0462010101010101010101010101010101010101010101010101010101010101010152682593fd0783ea60657ed2d118e8f958c4a7a198237749b6729eccf963be1bc559531ec4b83bcfc42009cd08f7e95747146cec2fd09571b3fa76656e3012a4c97a 0662030303030303030303030303030303030303030303030303030303030303030319bed0825ceb5acf504cddea72e37a75505290a22850c183725963edfe2dfb9f26e27180b210c05635987b80b3de3b7d01732653565b9f25ec23f7aff26122e00bff" -> ClosingComplete(channelId, closerScript, closeeScript, 1105 sat, 0, TlvStream(ClosingCompleteTlv.CloserOutputOnlyPartialSignature(PartialSignatureWithNonce(partialSig1, nonce1)), ClosingCompleteTlv.CloserAndCloseeOutputsPartialSignature(PartialSignatureWithNonce(partialSig3, nonce3)))), hex"0029 58a00a6f14e69a2e97b18cf76f755c8551fea9947cf7b6ece9d641013eba5f86 0004deadbeef 0006d43db3ef1234 0000000000000451 00000000" -> ClosingSig(channelId, closerScript, closeeScript, 1105 sat, 0), hex"0029 58a00a6f14e69a2e97b18cf76f755c8551fea9947cf7b6ece9d641013eba5f86 0004deadbeef 0006d43db3ef1234 0000000000000451 00000000 024001010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101" -> ClosingSig(channelId, closerScript, closeeScript, 1105 sat, 0, TlvStream(ClosingTlv.CloseeOutputOnly(sig1))), hex"0029 58a00a6f14e69a2e97b18cf76f755c8551fea9947cf7b6ece9d641013eba5f86 0004deadbeef 0006d43db3ef1234 0000000000000451 00000000 034001010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101" -> ClosingSig(channelId, closerScript, closeeScript, 1105 sat, 0, TlvStream(ClosingTlv.CloserAndCloseeOutputs(sig1))), hex"0029 58a00a6f14e69a2e97b18cf76f755c8551fea9947cf7b6ece9d641013eba5f86 0004deadbeef 0006d43db3ef1234 0000000000000451 00000000 014001010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101 034002020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202" -> ClosingSig(channelId, closerScript, closeeScript, 1105 sat, 0, TlvStream(ClosingTlv.CloserOutputOnly(sig1), ClosingTlv.CloserAndCloseeOutputs(sig2))), hex"0029 58a00a6f14e69a2e97b18cf76f755c8551fea9947cf7b6ece9d641013eba5f86 0004deadbeef 0006d43db3ef1234 0000000000000451 00000000 014001010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101 024002020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202 034003030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303" -> ClosingSig(channelId, closerScript, closeeScript, 1105 sat, 0, TlvStream(ClosingTlv.CloserOutputOnly(sig1), ClosingTlv.CloseeOutputOnly(sig2), ClosingTlv.CloserAndCloseeOutputs(sig3))), + hex"0029 58a00a6f14e69a2e97b18cf76f755c8551fea9947cf7b6ece9d641013eba5f86 0004deadbeef 0006d43db3ef1234 0000000000000451 00000000 04200101010101010101010101010101010101010101010101010101010101010101" -> ClosingSig(channelId, closerScript, closeeScript, 1105 sat, 0, TlvStream(ClosingSigTlv.CloserOutputOnlyPartialSignature(partialSig1))), + hex"0029 58a00a6f14e69a2e97b18cf76f755c8551fea9947cf7b6ece9d641013eba5f86 0004deadbeef 0006d43db3ef1234 0000000000000451 00000000 05200202020202020202020202020202020202020202020202020202020202020202 06200303030303030303030303030303030303030303030303030303030303030303" -> ClosingSig(channelId, closerScript, closeeScript, 1105 sat, 0, TlvStream(ClosingSigTlv.CloseeOutputOnlyPartialSignature(partialSig2), ClosingSigTlv.CloserAndCloseeOutputsPartialSignature(partialSig3))), ) for ((encoded, expected) <- testCases) { val decoded = lightningMessageCodec.decode(encoded.bits).require.value @@ -565,7 +599,7 @@ class LightningMessageCodecsSpec extends AnyFunSuite { UpdateFulfillHtlc(randomBytes32(), 2, bin32(0)), UpdateFailHtlc(randomBytes32(), 2, bin(154, 0)), UpdateFailMalformedHtlc(randomBytes32(), 2, randomBytes32(), 1111), - CommitSig(randomBytes32(), randomBytes64(), randomBytes64() :: randomBytes64() :: randomBytes64() :: Nil), + CommitSig(randomBytes32(), IndividualSignature(randomBytes64()), randomBytes64() :: randomBytes64() :: randomBytes64() :: Nil), RevokeAndAck(randomBytes32(), scalar(0), point(1)), ChannelAnnouncement(randomBytes64(), randomBytes64(), randomBytes64(), randomBytes64(), Features(bin(7, 9)), Block.RegtestGenesisBlock.hash, RealShortChannelId(1), randomKey().publicKey, randomKey().publicKey, randomKey().publicKey, randomKey().publicKey), NodeAnnouncement(randomBytes64(), Features(DataLossProtect -> Optional), 1 unixsec, randomKey().publicKey, Color(100.toByte, 200.toByte, 300.toByte), "node-alias", IPv4(InetAddress.getByAddress(Array[Byte](192.toByte, 168.toByte, 1.toByte, 42.toByte)).asInstanceOf[Inet4Address], 42000) :: Nil), From 6b2a5ff6b34f3a5185b918109151b644fbfbceba Mon Sep 17 00:00:00 2001 From: t-bast Date: Fri, 1 Aug 2025 12:21:35 +0200 Subject: [PATCH 07/18] Fix `channel_ready` retransmission We were missing our local nonce when retransmitting `channel_ready` on reconnection for taproot channels. --- .../fr/acinq/eclair/channel/fsm/Channel.scala | 3 +-- .../channel/fsm/CommonFundingHandlers.scala | 20 ++++++++----------- .../wire/protocol/LightningMessageTypes.scala | 15 ++++++++++++++ .../protocol/LightningMessageCodecsSpec.scala | 3 ++- 4 files changed, 26 insertions(+), 15 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala index e1b6e9d6c2..8226d4da73 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala @@ -2669,8 +2669,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall val notAnnouncedYet = d.commitments.announceChannel && c.shortChannelId_opt.nonEmpty && d.lastAnnouncement_opt.isEmpty if (notAnnouncedYet || notReceivedByRemote || notReceivedByRemoteLegacy) { log.debug("re-sending channel_ready") - val nextPerCommitmentPoint = channelKeys.commitmentPoint(1) - sendQueue = sendQueue :+ ChannelReady(d.commitments.channelId, nextPerCommitmentPoint) + sendQueue = sendQueue :+ createChannelReady(d.aliases, d.commitments) } if (notAnnouncedYet) { // The funding transaction is confirmed, so we've already sent our announcement_signatures. 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 caf3b08e09..2f1a320e02 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 @@ -75,12 +75,9 @@ trait CommonFundingHandlers extends CommonHandlers { case _: SingleFundedUnconfirmedFundingTx => // in the single-funding case, as fundee, it is the first time we see the full funding tx, we must verify that it is // valid (it pays the correct amount to the correct script). We also check as funder even if it's not really useful - d.commitments.latest.fullySignedLocalCommitTx(channelKeys).map(signedTx => Try(Transaction.correctlySpends(signedTx, Seq(w.tx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS))) match { - case Right(Success(_)) => () - case Right(Failure(t)) => - log.error(t, s"rejecting channel with invalid funding tx: ${w.tx.bin}") - throw InvalidFundingTx(d.channelId) - case Left(t) => + d.commitments.latest.fullySignedLocalCommitTx(channelKeys).toTry.flatMap(signedTx => Try(Transaction.correctlySpends(signedTx, Seq(w.tx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS))) match { + case Success(_) => () + case Failure(t) => log.error(t, s"rejecting channel with invalid funding tx: ${w.tx.bin}") throw InvalidFundingTx(d.channelId) } @@ -129,16 +126,15 @@ trait CommonFundingHandlers extends CommonHandlers { def createChannelReady(aliases: ShortIdAliases, commitments: Commitments): ChannelReady = { val params = commitments.channelParams val nextPerCommitmentPoint = channelKeys.commitmentPoint(1) - val nextLocalNonce_opt = commitments.latest.commitmentFormat match { + // Note that we always send our local alias, even if it isn't explicitly supported, that's an optional TLV anyway. + commitments.latest.commitmentFormat match { case _: SimpleTaprootChannelCommitmentFormat => val localFundingKey = channelKeys.fundingKey(fundingTxIndex = 0) - val nextLocalNonce = NonceGenerator.verificationNonce(commitments.latest.fundingTxId, localFundingKey, commitments.latest.remoteFundingPubKey, 1).publicNonce - Some(ChannelTlv.NextLocalNonceTlv(nextLocalNonce)) + val nextLocalNonce = NonceGenerator.verificationNonce(commitments.latest.fundingTxId, localFundingKey, commitments.latest.remoteFundingPubKey, 1) + ChannelReady(params.channelId, nextPerCommitmentPoint, aliases.localAlias, nextLocalNonce.publicNonce) case _: AnchorOutputsCommitmentFormat | DefaultCommitmentFormat => - None + ChannelReady(params.channelId, nextPerCommitmentPoint, aliases.localAlias) } - // we always send our local alias, even if it isn't explicitly supported, that's an optional TLV anyway - ChannelReady(params.channelId, nextPerCommitmentPoint, TlvStream(Set(Some(ChannelReadyTlv.ShortChannelIdTlv(aliases.localAlias)), nextLocalNonce_opt).flatten[ChannelReadyTlv])) } def receiveChannelReady(aliases: ShortIdAliases, channelReady: ChannelReady, commitments: Commitments): DATA_NORMAL = { 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 e83ea5c0ae..6dc9d7e404 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 @@ -326,6 +326,21 @@ case class ChannelReady(channelId: ByteVector32, val nextLocalNonce_opt: Option[IndividualNonce] = tlvStream.get[ChannelTlv.NextLocalNonceTlv].map(_.nonce) } +object ChannelReady { + def apply(channelId: ByteVector32, nextPerCommitmentPoint: PublicKey, alias: Alias): ChannelReady = { + val tlvs = TlvStream[ChannelReadyTlv](ChannelReadyTlv.ShortChannelIdTlv(alias)) + ChannelReady(channelId, nextPerCommitmentPoint, tlvs) + } + + def apply(channelId: ByteVector32, nextPerCommitmentPoint: PublicKey, alias: Alias, nextCommitNonce: IndividualNonce): ChannelReady = { + val tlvs = TlvStream[ChannelReadyTlv]( + ChannelReadyTlv.ShortChannelIdTlv(alias), + ChannelTlv.NextLocalNonceTlv(nextCommitNonce), + ) + ChannelReady(channelId, nextPerCommitmentPoint, tlvs) + } +} + case class Stfu(channelId: ByteVector32, initiator: Boolean) extends SetupMessage with HasChannelId case class SpliceInit(channelId: ByteVector32, 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 31d202a182..993921da93 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 @@ -591,7 +591,8 @@ class LightningMessageCodecsSpec extends AnyFunSuite { FundingCreated(randomBytes32(), TxId(ByteVector32.Zeroes), 3, randomBytes64()), FundingSigned(randomBytes32(), randomBytes64()), ChannelReady(randomBytes32(), point(2)), - ChannelReady(randomBytes32(), point(2), TlvStream(ChannelReadyTlv.ShortChannelIdTlv(Alias(123456)))), + ChannelReady(randomBytes32(), point(2), Alias(123456)), + ChannelReady(randomBytes32(), point(2), Alias(123456), new IndividualNonce(randomBytes(66).toArray)), UpdateFee(randomBytes32(), FeeratePerKw(2 sat)), Shutdown(randomBytes32(), bin(47, 0)), ClosingSigned(randomBytes32(), 2 sat, randomBytes64()), From 40bda818c10cc6cb8e1a1814807c43f99a3b87a8 Mon Sep 17 00:00:00 2001 From: t-bast Date: Fri, 1 Aug 2025 15:27:20 +0200 Subject: [PATCH 08/18] Refactor closing helpers We refactor the taproot closing helpers and fix a bug where the `closer` TLV was filled instead of using the `closee` TLV (when signing remote transactions in `signSimpleClosingTx`). The diff with the base branch may look complex, but it is actually to make the diff with `master` much smaller and easier to review, by grouping logic that applies to both taproot and non-taproot cases. We also move the `shutdown` creation helpers to a separate file, like what is done for the `channel_ready` creation helper. --- .../fr/acinq/eclair/channel/Helpers.scala | 202 ++++++++---------- .../fr/acinq/eclair/channel/fsm/Channel.scala | 30 +-- .../eclair/channel/fsm/CommonHandlers.scala | 17 +- .../eclair/transactions/Transactions.scala | 17 +- 4 files changed, 126 insertions(+), 140 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala index 875f67e0e2..9f93948914 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala @@ -23,6 +23,7 @@ import fr.acinq.bitcoin.scalacompat._ import fr.acinq.eclair._ import fr.acinq.eclair.blockchain.OnChainPubkeyCache import fr.acinq.eclair.blockchain.fee._ +import fr.acinq.eclair.channel.ChannelSpendSignature.{IndividualSignature, PartialSignatureWithNonce} import fr.acinq.eclair.channel.fsm.Channel import fr.acinq.eclair.channel.fsm.Channel.REFRESH_CHANNEL_UPDATE_INTERVAL import fr.acinq.eclair.crypto.keymanager.{ChannelKeys, LocalCommitmentKeys, RemoteCommitmentKeys} @@ -694,7 +695,7 @@ object Helpers { // this is just to estimate the weight, it depends on size of the pubkey scripts val dummyClosingTx = ClosingTx.createUnsignedTx(commitment.commitInput(channelKeys), localScriptPubkey, remoteScriptPubkey, commitment.localChannelParams.paysClosingFees, 0 sat, 0 sat, commitment.localCommit.spec) val dummyPubkey = commitment.remoteFundingPubKey - val dummySig = ChannelSpendSignature.IndividualSignature(Transactions.PlaceHolderSig) + val dummySig = IndividualSignature(Transactions.PlaceHolderSig) val closingWeight = dummyClosingTx.aggregateSigs(dummyPubkey, dummyPubkey, dummySig, dummySig).weight() log.info(s"using feerates=$feerates for initial closing tx") feerates.computeFees(closingWeight) @@ -739,8 +740,8 @@ object Helpers { val (closingTx, closingSigned) = makeClosingTx(channelKeys, commitment, localScriptPubkey, remoteScriptPubkey, ClosingFees(remoteClosingFee, remoteClosingFee, remoteClosingFee)) if (checkClosingDustAmounts(closingTx)) { val fundingPubkey = channelKeys.fundingKey(commitment.fundingTxIndex).publicKey - if (closingTx.checkRemoteSig(fundingPubkey, commitment.remoteFundingPubKey, ChannelSpendSignature.IndividualSignature(remoteClosingSig))) { - val signedTx = closingTx.aggregateSigs(fundingPubkey, commitment.remoteFundingPubKey, ChannelSpendSignature.IndividualSignature(closingSigned.signature), ChannelSpendSignature.IndividualSignature(remoteClosingSig)) + if (closingTx.checkRemoteSig(fundingPubkey, commitment.remoteFundingPubKey, IndividualSignature(remoteClosingSig))) { + val signedTx = closingTx.aggregateSigs(fundingPubkey, commitment.remoteFundingPubKey, IndividualSignature(closingSigned.signature), IndividualSignature(remoteClosingSig)) Right(closingTx.copy(tx = signedTx), closingSigned) } else { Left(InvalidCloseSignature(commitment.channelId, closingTx.tx.txid)) @@ -750,10 +751,8 @@ object Helpers { } } - case class ClosingCompleteNonces(closerAndCloseeOutputsNonce: Option[LocalNonce], closerOutputOnlyNonce: Option[LocalNonce], closeeOutputOnlyNonce: Option[LocalNonce]) - /** We are the closer: we sign closing transactions for which we pay the fees. */ - def makeSimpleClosingTx(currentBlockHeight: BlockHeight, channelKeys: ChannelKeys, commitment: FullCommitment, localScriptPubkey: ByteVector, remoteScriptPubkey: ByteVector, feerate: FeeratePerKw, remoteClosingNonce_opt: Option[IndividualNonce] = None): Either[ChannelException, (ClosingTxs, ClosingComplete, ClosingCompleteNonces)] = { + def makeSimpleClosingTx(currentBlockHeight: BlockHeight, channelKeys: ChannelKeys, commitment: FullCommitment, localScriptPubkey: ByteVector, remoteScriptPubkey: ByteVector, feerate: FeeratePerKw, remoteNonce_opt: Option[IndividualNonce] = None): Either[ChannelException, (ClosingTxs, ClosingComplete, CloserNonces)] = { // We must convert the feerate to a fee: we must build dummy transactions to compute their weight. val commitInput = commitment.commitInput(channelKeys) val closingFee = { @@ -763,12 +762,11 @@ object Helpers { commitment.commitmentFormat match { case DefaultCommitmentFormat | _: AnchorOutputsCommitmentFormat => val dummyPubkey = commitment.remoteFundingPubKey - val dummySig = ChannelSpendSignature.IndividualSignature(Transactions.PlaceHolderSig) + val dummySig = IndividualSignature(Transactions.PlaceHolderSig) val dummySignedTx = dummyTx.aggregateSigs(dummyPubkey, dummyPubkey, dummySig, dummySig) SimpleClosingTxFee.PaidByUs(Transactions.weight2fee(feerate, dummySignedTx.weight())) case _: SimpleTaprootChannelCommitmentFormat => - val dummySig = ChannelSpendSignature.IndividualSignature(Transactions.PlaceHolderSig) - val dummySignedTx = dummyTx.tx.updateWitness(dummyTx.inputIndex, Script.witnessKeyPathPay2tr(dummySig.sig)) + val dummySignedTx = dummyTx.tx.updateWitness(dummyTx.inputIndex, Script.witnessKeyPathPay2tr(Transactions.PlaceHolderSig)) SimpleClosingTxFee.PaidByUs(Transactions.weight2fee(feerate, dummySignedTx.weight())) } case None => return Left(CannotGenerateClosingTx(commitment.channelId)) @@ -781,38 +779,24 @@ object Helpers { case _ => return Left(CannotGenerateClosingTx(commitment.channelId)) } val localFundingKey = channelKeys.fundingKey(commitment.fundingTxIndex) - - var closingCompleteNonces = ClosingCompleteNonces(None, None, None) + val localNonces = CloserNonces.generate(localFundingKey.publicKey, commitment.remoteFundingPubKey, commitment.fundingTxId) val tlvs: TlvStream[ClosingCompleteTlv] = commitment.commitmentFormat match { case _: SimpleTaprootChannelCommitmentFormat => - if (remoteClosingNonce_opt.isEmpty) return Left(MissingShutdownNonce(commitment.channelId)) - try { - // generate a partial signature to send to our peer, using a random signing nonce and their closing nonce - def partialSign(tx: ClosingTx, localNonce: LocalNonce): ChannelSpendSignature.PartialSignatureWithNonce = { - val Right(psig) = tx.partialSign(localFundingKey, commitment.remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteClosingNonce_opt.get)) - psig - } + remoteNonce_opt match { + case None => return Left(MissingShutdownNonce(commitment.channelId)) + case Some(remoteNonce) => + // If we cannot create our partial signature for one of our closing txs, we just skip it. + // It will only happen if our peer sent an invalid nonce, in which case we cannot do anything anyway + // apart from eventually force-closing. + def localSig(tx: ClosingTx, localNonce: LocalNonce): Option[PartialSignatureWithNonce] = { + tx.partialSign(localFundingKey, commitment.remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteNonce)).toOption + } - TlvStream(Set( - closingTxs.localAndRemote_opt.map(tx => { - val localNonce = NonceGenerator.signingNonce(localFundingKey.publicKey, commitment.remoteFundingPubKey, commitment.fundingTxId) - closingCompleteNonces = closingCompleteNonces.copy(closerAndCloseeOutputsNonce = Some(localNonce)) - ClosingCompleteTlv.CloserAndCloseeOutputsPartialSignature(partialSign(tx, localNonce)) - }), - closingTxs.localOnly_opt.map(tx => { - val localNonce = NonceGenerator.signingNonce(localFundingKey.publicKey, commitment.remoteFundingPubKey, commitment.fundingTxId) - closingCompleteNonces = closingCompleteNonces.copy(closerOutputOnlyNonce = Some(localNonce)) - ClosingCompleteTlv.CloserOutputOnlyPartialSignature(partialSign(tx, localNonce)) - }), - closingTxs.remoteOnly_opt.map(tx => { - val localNonce = NonceGenerator.signingNonce(localFundingKey.publicKey, commitment.remoteFundingPubKey, commitment.fundingTxId) - closingCompleteNonces = closingCompleteNonces.copy(closeeOutputOnlyNonce = Some(localNonce)) - ClosingCompleteTlv.CloseeOutputOnlyPartialSignature(partialSign(tx, localNonce)) - }), - ).flatten[ClosingCompleteTlv]) - } - catch { - case _: Throwable => return Left(CannotGenerateClosingTx(commitment.channelId)) + TlvStream(Set( + closingTxs.localAndRemote_opt.flatMap(tx => localSig(tx, localNonces.localAndRemote)).map(ClosingCompleteTlv.CloserAndCloseeOutputsPartialSignature(_)), + closingTxs.localOnly_opt.flatMap(tx => localSig(tx, localNonces.localOnly)).map(ClosingCompleteTlv.CloserOutputOnlyPartialSignature(_)), + closingTxs.remoteOnly_opt.flatMap(tx => localSig(tx, localNonces.remoteOnly)).map(ClosingCompleteTlv.CloseeOutputOnlyPartialSignature(_)), + ).flatten[ClosingCompleteTlv]) } case _: AnchorOutputsCommitmentFormat | DefaultCommitmentFormat => TlvStream(Set( closingTxs.localAndRemote_opt.map(tx => ClosingTlv.CloserAndCloseeOutputs(tx.sign(localFundingKey, commitment.remoteFundingPubKey).sig)), @@ -820,9 +804,8 @@ object Helpers { closingTxs.remoteOnly_opt.map(tx => ClosingTlv.CloseeOutputOnly(tx.sign(localFundingKey, commitment.remoteFundingPubKey).sig)), ).flatten[ClosingCompleteTlv]) } - val closingComplete = ClosingComplete(commitment.channelId, localScriptPubkey, remoteScriptPubkey, closingFee.fee, currentBlockHeight.toLong, tlvs) - Right(closingTxs, closingComplete, closingCompleteNonces) + Right(closingTxs, closingComplete, localNonces) } /** @@ -831,43 +814,44 @@ object Helpers { * Callers should ignore failures: since the protocol is fully asynchronous, failures here simply mean that they * are not using our latest script (race condition between our closing_complete and theirs). */ - def signSimpleClosingTx(channelKeys: ChannelKeys, commitment: FullCommitment, localScriptPubkey: ByteVector, remoteScriptPubkey: ByteVector, closingComplete: ClosingComplete, localClosingNonce_opt: Option[LocalNonce]): Either[ChannelException, (ClosingTx, ClosingSig, Option[LocalNonce])] = { + def signSimpleClosingTx(channelKeys: ChannelKeys, commitment: FullCommitment, localScriptPubkey: ByteVector, remoteScriptPubkey: ByteVector, closingComplete: ClosingComplete, localNonce_opt: Option[LocalNonce]): Either[ChannelException, (ClosingTx, ClosingSig, Option[LocalNonce])] = { val closingFee = SimpleClosingTxFee.PaidByThem(closingComplete.fees) val closingTxs = Transactions.makeSimpleClosingTxs(commitment.commitInput(channelKeys), commitment.localCommit.spec, closingFee, closingComplete.lockTime, localScriptPubkey, remoteScriptPubkey) // If our output isn't dust, they must provide a signature for a transaction that includes it. // Note that we're the closee, so we look for signatures including the closee output. - commitment.commitmentFormat match { - case _: SimpleTaprootChannelCommitmentFormat if localClosingNonce_opt.isEmpty => Left(MissingShutdownNonce(commitment.channelId)) - case _: SimpleTaprootChannelCommitmentFormat => - (closingTxs.localAndRemote_opt, closingTxs.localOnly_opt) match { - case (Some(_), Some(_)) if closingComplete.closerAndCloseeOutputsPartialSig_opt.isEmpty && closingComplete.closeeOutputOnlyPartialSig_opt.isEmpty => return Left(MissingCloseSignature(commitment.channelId)) - case (Some(_), None) if closingComplete.closerAndCloseeOutputsPartialSig_opt.isEmpty => return Left(MissingCloseSignature(commitment.channelId)) - case (None, Some(_)) if closingComplete.closeeOutputOnlyPartialSig_opt.isEmpty => return Left(MissingCloseSignature(commitment.channelId)) - case _ => () - } - // We choose the closing signature that matches our preferred closing transaction. - val closingTxsWithSigs = Seq( - closingComplete.closerAndCloseeOutputsPartialSig_opt.flatMap(remoteSig => closingTxs.localAndRemote_opt.map(tx => (tx, remoteSig, localSig => ClosingSigTlv.CloserAndCloseeOutputsPartialSignature(localSig)))), - closingComplete.closeeOutputOnlyPartialSig_opt.flatMap(remoteSig => closingTxs.localOnly_opt.map(tx => (tx, remoteSig, localSig => ClosingSigTlv.CloserOutputOnlyPartialSignature(localSig)))), - closingComplete.closerOutputOnlyPartialSig_opt.flatMap(remoteSig => closingTxs.remoteOnly_opt.map(tx => (tx, remoteSig, localSig => ClosingSigTlv.CloserOutputOnlyPartialSignature(localSig)))), - ).flatten - closingTxsWithSigs.headOption match { - case Some((closingTx, remoteSig, sigToTlv)) => - val localFundingKey = channelKeys.fundingKey(commitment.fundingTxIndex) - (for { - // generate a local partial signature using our closing nonce (the one we sent to our peer in our Shutdown message) - localSig <- closingTx.partialSign(localFundingKey, commitment.remoteFundingPubKey, localClosingNonce_opt.get, Seq(localClosingNonce_opt.get.publicNonce, remoteSig.nonce)) - tx <- closingTx.aggregateSigs(localFundingKey.publicKey, commitment.remoteFundingPubKey, localSig, remoteSig) - } yield (closingTx.copy(tx = tx), localSig)) match { - case Right((signedClosingTx, localSig)) if signedClosingTx.validate(Map.empty) => - val nextClosingNonce = NonceGenerator.signingNonce(localFundingKey.publicKey, commitment.remoteFundingPubKey, commitment.fundingTxId) - Right(signedClosingTx, ClosingSig(commitment.channelId, remoteScriptPubkey, localScriptPubkey, closingComplete.fees, closingComplete.lockTime, TlvStream(sigToTlv(localSig.partialSig), ClosingSigTlv.NextCloseeNonce(nextClosingNonce.publicNonce))), Some(nextClosingNonce)) - case _ => - Left(InvalidCloseSignature(commitment.channelId, closingTx.tx.txid)) - } - case None => Left(MissingCloseSignature(commitment.channelId)) - } + case _: SimpleTaprootChannelCommitmentFormat => localNonce_opt match { + case None => Left(MissingShutdownNonce(commitment.channelId)) + case Some(localNonce) => + (closingTxs.localAndRemote_opt, closingTxs.localOnly_opt) match { + case (Some(_), Some(_)) if closingComplete.closerAndCloseeOutputsPartialSig_opt.isEmpty && closingComplete.closeeOutputOnlyPartialSig_opt.isEmpty => return Left(MissingCloseSignature(commitment.channelId)) + case (Some(_), None) if closingComplete.closerAndCloseeOutputsPartialSig_opt.isEmpty => return Left(MissingCloseSignature(commitment.channelId)) + case (None, Some(_)) if closingComplete.closeeOutputOnlyPartialSig_opt.isEmpty => return Left(MissingCloseSignature(commitment.channelId)) + case _ => () + } + // We choose the closing signature that matches our preferred closing transaction. + val closingTxsWithSigs = Seq( + closingComplete.closerAndCloseeOutputsPartialSig_opt.flatMap(remoteSig => closingTxs.localAndRemote_opt.map(tx => (tx, remoteSig, localSig => ClosingSigTlv.CloserAndCloseeOutputsPartialSignature(localSig)))), + closingComplete.closeeOutputOnlyPartialSig_opt.flatMap(remoteSig => closingTxs.localOnly_opt.map(tx => (tx, remoteSig, localSig => ClosingSigTlv.CloseeOutputOnlyPartialSignature(localSig)))), + closingComplete.closerOutputOnlyPartialSig_opt.flatMap(remoteSig => closingTxs.remoteOnly_opt.map(tx => (tx, remoteSig, localSig => ClosingSigTlv.CloserOutputOnlyPartialSignature(localSig)))), + ).flatten + closingTxsWithSigs.headOption match { + case Some((closingTx, remoteSig, sigToTlv)) => + val localFundingKey = channelKeys.fundingKey(commitment.fundingTxIndex) + val signedClosingTx_opt = for { + localSig <- closingTx.partialSign(localFundingKey, commitment.remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteSig.nonce)).toOption + signedTx <- closingTx.aggregateSigs(localFundingKey.publicKey, commitment.remoteFundingPubKey, localSig, remoteSig).toOption + } yield (closingTx.copy(tx = signedTx), localSig.partialSig) + signedClosingTx_opt match { + case Some((signedClosingTx, localSig)) if signedClosingTx.validate(extraUtxos = Map.empty) => + val nextLocalNonce = NonceGenerator.signingNonce(localFundingKey.publicKey, commitment.remoteFundingPubKey, commitment.fundingTxId) + val tlvs = TlvStream[ClosingSigTlv](sigToTlv(localSig), ClosingSigTlv.NextCloseeNonce(nextLocalNonce.publicNonce)) + Right(signedClosingTx, ClosingSig(commitment.channelId, remoteScriptPubkey, localScriptPubkey, closingComplete.fees, closingComplete.lockTime, tlvs), Some(nextLocalNonce)) + case _ => Left(InvalidCloseSignature(commitment.channelId, closingTx.tx.txid)) + } + case None => Left(MissingCloseSignature(commitment.channelId)) + } + } case _: AnchorOutputsCommitmentFormat | DefaultCommitmentFormat => (closingTxs.localAndRemote_opt, closingTxs.localOnly_opt) match { case (Some(_), Some(_)) if closingComplete.closerAndCloseeOutputsSig_opt.isEmpty && closingComplete.closeeOutputOnlySig_opt.isEmpty => return Left(MissingCloseSignature(commitment.channelId)) @@ -885,7 +869,7 @@ object Helpers { case Some((closingTx, remoteSig, sigToTlv)) => val localFundingKey = channelKeys.fundingKey(commitment.fundingTxIndex) val localSig = closingTx.sign(localFundingKey, commitment.remoteFundingPubKey) - val signedTx = closingTx.aggregateSigs(localFundingKey.publicKey, commitment.remoteFundingPubKey, localSig, ChannelSpendSignature.IndividualSignature(remoteSig)) + val signedTx = closingTx.aggregateSigs(localFundingKey.publicKey, commitment.remoteFundingPubKey, localSig, IndividualSignature(remoteSig)) val signedClosingTx = closingTx.copy(tx = signedTx) if (signedClosingTx.validate(extraUtxos = Map.empty)) { Right(signedClosingTx, ClosingSig(commitment.channelId, remoteScriptPubkey, localScriptPubkey, closingComplete.fees, closingComplete.lockTime, TlvStream(sigToTlv(localSig.sig))), None) @@ -904,50 +888,40 @@ object Helpers { * sent another closing_complete before receiving their closing_sig, which is now obsolete: we ignore it and wait * for their next closing_sig that will match our latest closing_complete. */ - def receiveSimpleClosingSig(channelKeys: ChannelKeys, commitment: FullCommitment, closingTxs: ClosingTxs, closingSig: ClosingSig, remoteNonce_opt: Option[IndividualNonce], closingCompleteNonces: ClosingCompleteNonces): Either[ChannelException, ClosingTx] = { - val localFundingKey = channelKeys.fundingKey(commitment.fundingTxIndex) - commitment.commitmentFormat match { - case _: SimpleTaprootChannelCommitmentFormat => - val closingTxsWithSig = Seq( - closingSig.closerAndCloseeOutputsPartialSig_opt.flatMap(sig => closingTxs.localAndRemote_opt.map(tx => (tx, sig, closingCompleteNonces.closerAndCloseeOutputsNonce))), - closingSig.closerOutputOnlyPartialSig_opt.flatMap(sig => closingTxs.localOnly_opt.map(tx => (tx, sig, closingCompleteNonces.closerOutputOnlyNonce))), - closingSig.closeeOutputOnlyPartialSig_opt.flatMap(sig => closingTxs.remoteOnly_opt.map(tx => (tx, sig, closingCompleteNonces.closeeOutputOnlyNonce))), - ).flatten - closingTxsWithSig.headOption match { - case Some((closingTx, remoteSig, localNonce)) if !closingTx.checkRemotePartialSignature( - localFundingKey.publicKey, commitment.remoteFundingPubKey, - ChannelSpendSignature.PartialSignatureWithNonce(remoteSig, remoteNonce_opt.get), localNonce.get.publicNonce) => Left(InvalidCloseSignature(commitment.channelId, closingTx.tx.txid)) - case Some((closingTx, remoteSig, localNonce)) => - (for { - localSig <- closingTx.partialSign(localFundingKey, commitment.remoteFundingPubKey, localNonce.get, Seq(localNonce.get.publicNonce, remoteNonce_opt.get)) - tx <- closingTx.aggregateSigs(localFundingKey.publicKey, commitment.remoteFundingPubKey, localSig, ChannelSpendSignature.PartialSignatureWithNonce(remoteSig, remoteNonce_opt.get)) - signedClosingTx = closingTx.copy(tx = tx) - } yield signedClosingTx) match { - case Left(_) => Left(InvalidCloseSignature(commitment.channelId, closingTx.tx.txid)) - case Right(signedClosing) if !signedClosing.validate(Map.empty) => Left(InvalidCloseSignature(commitment.channelId, closingTx.tx.txid)) - case Right(signedClosing) => Right(signedClosing) - } - case None => Left(MissingCloseSignature(commitment.channelId)) - } - case _: AnchorOutputsCommitmentFormat | DefaultCommitmentFormat => - val closingTxsWithSig = Seq( - closingSig.closerAndCloseeOutputsSig_opt.flatMap(sig => closingTxs.localAndRemote_opt.map(tx => (tx, sig))), - closingSig.closerOutputOnlySig_opt.flatMap(sig => closingTxs.localOnly_opt.map(tx => (tx, sig))), - closingSig.closeeOutputOnlySig_opt.flatMap(sig => closingTxs.remoteOnly_opt.map(tx => (tx, sig))), - ).flatten - closingTxsWithSig.headOption match { - case Some((closingTx, remoteSig)) => - val localFundingKey = channelKeys.fundingKey(commitment.fundingTxIndex) + def receiveSimpleClosingSig(channelKeys: ChannelKeys, commitment: FullCommitment, closingTxs: ClosingTxs, closingSig: ClosingSig, localNonces_opt: Option[CloserNonces], remoteNonce_opt: Option[IndividualNonce]): Either[ChannelException, ClosingTx] = { + val closingTxsWithSig = Seq( + closingSig.closerAndCloseeOutputsSig_opt.flatMap(sig => closingTxs.localAndRemote_opt.map(tx => (tx, IndividualSignature(sig)))), + closingSig.closerAndCloseeOutputsPartialSig_opt.flatMap(sig => remoteNonce_opt.flatMap(nonce => closingTxs.localAndRemote_opt.map(tx => (tx, PartialSignatureWithNonce(sig, nonce))))), + closingSig.closerOutputOnlySig_opt.flatMap(sig => closingTxs.localOnly_opt.map(tx => (tx, IndividualSignature(sig)))), + closingSig.closerOutputOnlyPartialSig_opt.flatMap(sig => remoteNonce_opt.flatMap(nonce => closingTxs.localOnly_opt.map(tx => (tx, PartialSignatureWithNonce(sig, nonce))))), + closingSig.closeeOutputOnlySig_opt.flatMap(sig => closingTxs.remoteOnly_opt.map(tx => (tx, IndividualSignature(sig)))), + closingSig.closeeOutputOnlyPartialSig_opt.flatMap(sig => remoteNonce_opt.flatMap(nonce => closingTxs.remoteOnly_opt.map(tx => (tx, PartialSignatureWithNonce(sig, nonce))))) + ).flatten + closingTxsWithSig.headOption match { + case Some((closingTx, remoteSig)) => + val localFundingKey = channelKeys.fundingKey(commitment.fundingTxIndex) + val signedClosingTx_opt = remoteSig match { + case remoteSig: IndividualSignature => val localSig = closingTx.sign(localFundingKey, commitment.remoteFundingPubKey) - val signedTx = closingTx.aggregateSigs(localFundingKey.publicKey, commitment.remoteFundingPubKey, localSig, ChannelSpendSignature.IndividualSignature(remoteSig)) - val signedClosingTx = closingTx.copy(tx = signedTx) - if (signedClosingTx.validate(extraUtxos = Map.empty)) { - Right(signedClosingTx) - } else { - Left(InvalidCloseSignature(commitment.channelId, signedClosingTx.tx.txid)) + val signedTx = closingTx.aggregateSigs(localFundingKey.publicKey, commitment.remoteFundingPubKey, localSig, remoteSig) + Some(closingTx.copy(tx = signedTx)) + case remoteSig: PartialSignatureWithNonce => + val localNonce = localNonces_opt match { + case Some(localNonces) if closingTx.tx.txOut.size == 2 => localNonces.localAndRemote + case Some(localNonces) if closingTx.toLocalOutput_opt.nonEmpty => localNonces.localOnly + case Some(localNonces) => localNonces.remoteOnly + case None => return Left(InvalidCloseSignature(commitment.channelId, closingTx.tx.txid)) } - case None => Left(MissingCloseSignature(commitment.channelId)) + for { + localSig <- closingTx.partialSign(localFundingKey, commitment.remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteSig.nonce)).toOption + signedTx <- closingTx.aggregateSigs(localFundingKey.publicKey, commitment.remoteFundingPubKey, localSig, remoteSig).toOption + } yield closingTx.copy(tx = signedTx) + } + signedClosingTx_opt match { + case Some(signedClosingTx) if signedClosingTx.validate(extraUtxos = Map.empty) => Right(signedClosingTx) + case _ => Left(InvalidCloseSignature(commitment.channelId, closingTx.tx.txid)) } + case None => Left(MissingCloseSignature(commitment.channelId)) } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala index 8226d4da73..6bc3cbdd93 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala @@ -32,7 +32,6 @@ import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient import fr.acinq.eclair.channel.Commitments.PostRevocationAction import fr.acinq.eclair.channel.Helpers.Closing.MutualClose -import fr.acinq.eclair.channel.Helpers.Closing.MutualClose.ClosingCompleteNonces import fr.acinq.eclair.channel.Helpers.Syncing.SyncResult import fr.acinq.eclair.channel.Helpers._ import fr.acinq.eclair.channel.Monitoring.Metrics.ProcessMessage @@ -54,7 +53,6 @@ import fr.acinq.eclair.router.Announcements import fr.acinq.eclair.transactions.Transactions._ import fr.acinq.eclair.transactions._ import fr.acinq.eclair.wire.protocol._ -import scodec.bits.ByteVector import scala.collection.immutable.Queue import scala.concurrent.ExecutionContext @@ -230,20 +228,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall var localCloseeNonce_opt: Option[LocalNonce] = None var remoteCloseeNonce_opt: Option[IndividualNonce] = None // Closer nonces are randomly generated when sending our closing_complete. - var localCloserNonces: ClosingCompleteNonces = ClosingCompleteNonces(None, None, None) - - def createLocalShutdown(channelId: ByteVector32, finalScriptPubKey: ByteVector, commitments: Commitments): Shutdown = { - commitments.latest.commitmentFormat match { - case _: SimpleTaprootChannelCommitmentFormat => - // We create a fresh local closee nonce every time we send shutdown. - val localFundingPubKey = channelKeys.fundingKey(commitments.latest.fundingTxIndex).publicKey - val localCloseeNonce = NonceGenerator.signingNonce(localFundingPubKey, commitments.latest.remoteFundingPubKey, commitments.latest.fundingTxId) - localCloseeNonce_opt = Some(localCloseeNonce) - Shutdown(channelId, finalScriptPubKey, localCloseeNonce.publicNonce) - case _: AnchorOutputsCommitmentFormat | DefaultCommitmentFormat => - Shutdown(channelId, finalScriptPubKey) - } - } + var localCloserNonces_opt: Option[CloserNonces] = None def setRemoteNextLocalNonces(info: String, n: Map[TxId, IndividualNonce]): Unit = { remoteNextCommitNonces = n @@ -768,7 +753,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall if (d.remoteShutdown.isDefined && !commitments1.changes.localHasUnsignedOutgoingHtlcs) { // we were waiting for our pending htlcs to be signed before replying with our local shutdown val finalScriptPubKey = getOrGenerateFinalScriptPubKey(d) - val localShutdown = createLocalShutdown(d.channelId, finalScriptPubKey, d.commitments) + val localShutdown = createShutdown(d.commitments, finalScriptPubKey) // this should always be defined, we provide a fallback for backward compat with older channels val closeStatus = d.closeStatus_opt.getOrElse(CloseStatus.NonInitiator(None)) // note: it means that we had pending htlcs to sign, therefore we go to SHUTDOWN, not to NEGOTIATING @@ -795,7 +780,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall d.commitments.channelParams.validateLocalShutdownScript(localScriptPubKey) match { case Left(e) => handleCommandError(e, c) case Right(localShutdownScript) => - val shutdown = createLocalShutdown(d.channelId, localShutdownScript, d.commitments) + val shutdown = createShutdown(d.commitments, localShutdownScript) handleCommandSuccess(c, d.copy(localShutdown = Some(shutdown), closeStatus_opt = Some(CloseStatus.Initiator(c.feerates)))) storing() sending shutdown } } @@ -844,10 +829,9 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall remoteCloseeNonce_opt = remoteShutdown.closeeNonce_opt // so we don't have any unsigned outgoing changes val (localShutdown, sendList) = d.localShutdown match { - case Some(localShutdown) => - (localShutdown, Nil) + case Some(localShutdown) => (localShutdown, Nil) case None => - val localShutdown = createLocalShutdown(d.channelId, getOrGenerateFinalScriptPubKey(d), d.commitments) + val localShutdown = createShutdown(d.commitments, getOrGenerateFinalScriptPubKey(d)) // we need to send our shutdown if we didn't previously (localShutdown, localShutdown :: Nil) } @@ -1924,7 +1908,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall case Left(f) => handleCommandError(f, c) case Right((closingTxs, closingComplete, closerNonces)) => log.debug("signing local mutual close transactions: {}", closingTxs) - localCloserNonces = closerNonces + localCloserNonces_opt = Some(closerNonces) handleCommandSuccess(c, d.copy(lastClosingFeerate = closingFeerate, localScriptPubKey = localScript, proposedClosingTxs = d.proposedClosingTxs :+ closingTxs)) storing() sending closingComplete } } @@ -1953,7 +1937,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall // Note that if we sent two closing_complete in a row, without waiting for their closing_sig for the first one, // this will fail because we only care about our latest closing_complete. This is fine, we should receive their // closing_sig for the last closing_complete afterwards. - MutualClose.receiveSimpleClosingSig(channelKeys, d.commitments.latest, d.proposedClosingTxs.last, closingSig, remoteCloseeNonce_opt, localCloserNonces) match { + MutualClose.receiveSimpleClosingSig(channelKeys, d.commitments.latest, d.proposedClosingTxs.last, closingSig, localCloserNonces_opt, remoteCloseeNonce_opt) match { case Left(f) => log.warning("invalid closing_sig: {}", f.getMessage) remoteCloseeNonce_opt = closingSig.nextCloseeNonce_opt diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonHandlers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonHandlers.scala index 8b83a610d8..0833816cee 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonHandlers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonHandlers.scala @@ -21,8 +21,10 @@ import fr.acinq.bitcoin.scalacompat.ByteVector32 import fr.acinq.eclair.Features import fr.acinq.eclair.channel.Helpers.Closing.MutualClose import fr.acinq.eclair.channel._ +import fr.acinq.eclair.crypto.NonceGenerator import fr.acinq.eclair.db.PendingCommandsDb import fr.acinq.eclair.io.Peer +import fr.acinq.eclair.transactions.Transactions.{AnchorOutputsCommitmentFormat, DefaultCommitmentFormat, SimpleTaprootChannelCommitmentFormat} import fr.acinq.eclair.wire.protocol.{ClosingComplete, HtlcSettlementMessage, LightningMessage, Shutdown, UpdateMessage} import scodec.bits.ByteVector @@ -132,6 +134,19 @@ trait CommonHandlers { finalScriptPubkey } + def createShutdown(commitments: Commitments, finalScriptPubKey: ByteVector): Shutdown = { + commitments.latest.commitmentFormat match { + case _: SimpleTaprootChannelCommitmentFormat => + // We create a fresh local closee nonce every time we send shutdown. + val localFundingPubKey = channelKeys.fundingKey(commitments.latest.fundingTxIndex).publicKey + val localCloseeNonce = NonceGenerator.signingNonce(localFundingPubKey, commitments.latest.remoteFundingPubKey, commitments.latest.fundingTxId) + localCloseeNonce_opt = Some(localCloseeNonce) + Shutdown(commitments.channelId, finalScriptPubKey, localCloseeNonce.publicNonce) + case _: AnchorOutputsCommitmentFormat | DefaultCommitmentFormat => + Shutdown(commitments.channelId, finalScriptPubKey) + } + } + def startSimpleClose(commitments: Commitments, localShutdown: Shutdown, remoteShutdown: Shutdown, closeStatus: CloseStatus): (DATA_NEGOTIATING_SIMPLE, Option[ClosingComplete]) = { val localScript = localShutdown.scriptPubKey val remoteScript = remoteShutdown.scriptPubKey @@ -143,7 +158,7 @@ trait CommonHandlers { (d, None) case Right((closingTxs, closingComplete, closerNonces)) => log.debug("signing local mutual close transactions: {}", closingTxs) - localCloserNonces = closerNonces + localCloserNonces_opt = Some(closerNonces) val d = DATA_NEGOTIATING_SIMPLE(commitments, closingFeerate, localScript, remoteScript, closingTxs :: Nil, Nil) (d, Some(closingComplete)) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala index 528cc72973..85e68b766d 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala @@ -27,6 +27,7 @@ import fr.acinq.eclair._ import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.channel.ChannelSpendSignature import fr.acinq.eclair.channel.ChannelSpendSignature._ +import fr.acinq.eclair.crypto.NonceGenerator import fr.acinq.eclair.crypto.keymanager.{CommitmentPublicKeys, LocalCommitmentKeys, RemoteCommitmentKeys} import fr.acinq.eclair.transactions.CommitmentOutput._ import fr.acinq.eclair.transactions.Scripts.Taproot.NUMS_POINT @@ -1545,8 +1546,20 @@ object Transactions { } // @formatter:on - /** When sending [[fr.acinq.eclair.wire.protocol.ClosingComplete]], we use a random nonce for each closing transaction we create. */ - case class CloserNonces(localAndRemote_opt: Option[LocalNonce], localOnly_opt: Option[LocalNonce], remoteOnly_opt: Option[LocalNonce]) + /** + * When sending [[fr.acinq.eclair.wire.protocol.ClosingComplete]], we use a different nonce for each closing transaction we create. + * We generate nonces for all variants of the closing transaction for simplicity, even though we never use them all. + */ + case class CloserNonces(localAndRemote: LocalNonce, localOnly: LocalNonce, remoteOnly: LocalNonce) + + object CloserNonces { + /** Generate a set of random signing nonces for our closing transactions. */ + def generate(localFundingKey: PublicKey, remoteFundingKey: PublicKey, fundingTxId: TxId): CloserNonces = CloserNonces( + NonceGenerator.signingNonce(localFundingKey, remoteFundingKey, fundingTxId), + NonceGenerator.signingNonce(localFundingKey, remoteFundingKey, fundingTxId), + NonceGenerator.signingNonce(localFundingKey, remoteFundingKey, fundingTxId), + ) + } /** Each closing attempt can result in multiple potential closing transactions, depending on which outputs are included. */ case class ClosingTxs(localAndRemote_opt: Option[ClosingTx], localOnly_opt: Option[ClosingTx], remoteOnly_opt: Option[ClosingTx]) { From ce68c2008301c947153e64ad9eb74670fb034bb2 Mon Sep 17 00:00:00 2001 From: t-bast Date: Mon, 4 Aug 2025 09:48:19 +0200 Subject: [PATCH 09/18] nit: rename Phoenix simple taproot feature The `tweaked` suffix doesn't seem to be useful, we're already stating that this is specific to Phoenix. --- eclair-core/src/main/scala/fr/acinq/eclair/Features.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Features.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Features.scala index 59a8facfe1..27164347cf 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Features.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Features.scala @@ -342,7 +342,7 @@ object Features { } case object SimpleTaprootChannelsPhoenix extends Feature with InitFeature with NodeFeature with ChannelTypeFeature { - val rfcName = "option_simple_taproot_phoenix_tweaked" + val rfcName = "option_simple_taproot_phoenix" val mandatory = 564 } From 738650c67f75ba4bca99d75b5323d096abf7f05a Mon Sep 17 00:00:00 2001 From: t-bast Date: Mon, 4 Aug 2025 12:22:49 +0200 Subject: [PATCH 10/18] More detailed remote nonce exceptions We rename the exceptions on remote nonces to more clearly apply to the commit tx, funding tx or closing tx. We add more fields to help debug. We remove the unnecessary log line on setting the remote commit nonces (the messages are logged and contain the nonces TLV, which is enough for debugging). We clear nonces on disconnection, to avoid polluting a fresh connection with invalid data from the previous connection. We fix a few `channel_reestablish` bugs, where the *next* commit nonce was used instead of the *current* commit nonce when retransmitting our `commit_sig`. --- .../eclair/channel/ChannelExceptions.scala | 10 +- .../fr/acinq/eclair/channel/Commitments.scala | 14 +- .../fr/acinq/eclair/channel/Helpers.scala | 20 +- .../fr/acinq/eclair/channel/fsm/Channel.scala | 530 +++++++++--------- .../channel/fsm/ChannelOpenDualFunded.scala | 8 +- .../channel/fsm/ChannelOpenSingleFunded.scala | 163 +++--- .../channel/fsm/CommonFundingHandlers.scala | 2 +- .../channel/fund/InteractiveTxBuilder.scala | 13 +- .../wire/protocol/LightningMessageTypes.scala | 42 +- .../a/WaitForAcceptChannelStateSpec.scala | 6 +- .../a/WaitForOpenChannelStateSpec.scala | 8 +- 11 files changed, 426 insertions(+), 390 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala index 4a99d76424..74cd7c0f2b 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala @@ -153,9 +153,9 @@ case class ForbiddenDuringQuiescence (override val channelId: Byte case class ConcurrentRemoteSplice (override val channelId: ByteVector32) extends ChannelException(channelId, "splice attempt canceled, remote initiated splice before us") case class TooManySmallHtlcs (override val channelId: ByteVector32, number: Long, below: MilliSatoshi) extends ChannelJammingException(channelId, s"too many small htlcs: $number HTLCs below $below") case class ConfidenceTooLow (override val channelId: ByteVector32, confidence: Double, occupancy: Double) extends ChannelJammingException(channelId, s"confidence too low: confidence=$confidence occupancy=$occupancy") -case class MissingNonce (override val channelId: ByteVector32, fundingTxId: TxId) extends ChannelException(channelId, s"next nonce for funding tx $fundingTxId is missing") -case class InvalidNonce (override val channelId: ByteVector32, fundingTxId: TxId) extends ChannelException(channelId, s"next nonce for funding tx $fundingTxId is not valid") -case class MissingFundingNonce (override val channelId: ByteVector32) extends ChannelException(channelId, "missing funding nonce") -case class InvalidFundingNonce (override val channelId: ByteVector32) extends ChannelException(channelId, "invalid funding nonce") -case class MissingShutdownNonce (override val channelId: ByteVector32) extends ChannelException(channelId, "missing shutdown nonce") +case class MissingCommitNonce (override val channelId: ByteVector32, fundingTxId: TxId, commitmentNumber: Long) extends ChannelException(channelId, s"commit nonce for funding tx $fundingTxId and commitmentNumber=$commitmentNumber is missing") +case class InvalidCommitNonce (override val channelId: ByteVector32, fundingTxId: TxId, commitmentNumber: Long) extends ChannelException(channelId, s"commit nonce for funding tx $fundingTxId and commitmentNumber=$commitmentNumber is not valid") +case class MissingFundingNonce (override val channelId: ByteVector32, fundingTxId: TxId) extends ChannelException(channelId, s"funding nonce for funding tx $fundingTxId is missing") +case class InvalidFundingNonce (override val channelId: ByteVector32, fundingTxId: TxId) extends ChannelException(channelId, s"funding nonce for funding tx $fundingTxId is not valid") +case class MissingClosingNonce (override val channelId: ByteVector32) extends ChannelException(channelId, "closing nonce is missing") // @formatter:on \ No newline at end of file diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala index 362df2987f..1f202fbbb6 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala @@ -210,11 +210,11 @@ case class RemoteCommit(index: Long, spec: CommitmentSpec, txId: TxId, remotePer val sig = remoteCommitTx.sign(fundingKey, remoteFundingPubKey) Right(CommitSig(channelParams.channelId, sig, htlcSigs.toList)) case _: SimpleTaprootChannelCommitmentFormat if remoteNonce_opt.isEmpty => - Left(MissingNonce(channelParams.channelId, commitInput.outPoint.txid)) + Left(MissingCommitNonce(channelParams.channelId, commitInput.outPoint.txid, index)) case _: SimpleTaprootChannelCommitmentFormat => val localNonce = NonceGenerator.signingNonce(fundingKey.publicKey, remoteFundingPubKey, commitInput.outPoint.txid) remoteCommitTx.partialSign(fundingKey, remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteNonce_opt.get)) match { - case Left(_) => Left(InvalidNonce(channelParams.channelId, commitInput.outPoint.txid)) + case Left(_) => Left(InvalidCommitNonce(channelParams.channelId, commitInput.outPoint.txid, index)) case Right(psig) => Right(CommitSig(channelParams.channelId, psig, htlcSigs.toList)) } } @@ -668,10 +668,10 @@ case class Commitment(fundingTxIndex: Long, case _: SimpleTaprootChannelCommitmentFormat => val localNonce = NonceGenerator.signingNonce(fundingKey.publicKey, remoteFundingPubKey, fundingTxId) if (nextRemoteNonce_opt.isEmpty) - return Left(MissingNonce(params.channelId, fundingTxId)) + return Left(MissingCommitNonce(params.channelId, fundingTxId, remoteCommit.index + 1)) val Some(remoteNonce) = nextRemoteNonce_opt val psig = remoteCommitTx.partialSign(fundingKey, remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteNonce)) match { - case Left(_) => return Left(InvalidNonce(params.channelId, fundingTxId)) + case Left(_) => return Left(InvalidCommitNonce(params.channelId, fundingTxId, remoteCommit.index + 1)) case Right(psig) => psig } log.debug(s"sendCommit: creating partial sig $psig for remote commit tx ${remoteCommitTx.tx.txid} with fundingTxIndex = $fundingTxIndex remoteCommit.index (should add +1) = ${remoteCommit.index} remote nonce $remoteNonce and remoteNextPerCommitmentPoint = $remoteNextPerCommitmentPoint") @@ -1161,7 +1161,7 @@ case class Commitments(channelParams: ChannelParams, channelId = channelId, perCommitmentSecret = localPerCommitmentSecret, nextPerCommitmentPoint = localNextPerCommitmentPoint, - nextLocalNonces = localCommitNonces, + nextCommitNonces = localCommitNonces, ) val commitments1 = copy( changes = changes.copy( @@ -1178,7 +1178,9 @@ case class Commitments(channelParams: ChannelParams, remoteNextCommitInfo match { case Right(_) => Left(UnexpectedRevocation(channelId)) case Left(_) if revocation.perCommitmentSecret.publicKey != active.head.remoteCommit.remotePerCommitmentPoint => Left(InvalidRevocation(channelId)) - case Left(_) if active.exists(c => c.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] && !revocation.nextLocalNonces.contains(c.fundingTxId)) => Left(MissingNonce(channelId, latest.fundingTxId)) + case Left(_) if active.exists(c => c.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] && !revocation.nextCommitNonces.contains(c.fundingTxId)) => + val missingNonce = active.find(c => c.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] && !revocation.nextCommitNonces.contains(c.fundingTxId)).get + Left(MissingCommitNonce(channelId, missingNonce.fundingTxId, remoteCommitIndex + 1)) case Left(_) => // Since htlcs are shared across all commitments, we generate the actions only once based on the first commitment. val receivedHtlcs = changes.remoteChanges.signed.collect { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala index 9f93948914..07765555b2 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala @@ -134,7 +134,7 @@ object Helpers { val channelFeatures = ChannelFeatures(channelType, localFeatures, remoteFeatures, open.channelFlags.announceChannel) channelType.commitmentFormat match { - case _: SimpleTaprootChannelCommitmentFormat => if (open.nextLocalNonce_opt.isEmpty) return Left(MissingNonce(open.temporaryChannelId, TxId(ByteVector32.Zeroes))) + case _: SimpleTaprootChannelCommitmentFormat => if (open.commitNonce_opt.isEmpty) return Left(MissingCommitNonce(open.temporaryChannelId, TxId(ByteVector32.Zeroes), commitmentNumber = 0)) case _: AnchorOutputsCommitmentFormat | DefaultCommitmentFormat => () } @@ -245,7 +245,7 @@ object Helpers { val channelFeatures = ChannelFeatures(channelType, localFeatures, remoteFeatures, open.channelFlags.announceChannel) channelType.commitmentFormat match { - case _: SimpleTaprootChannelCommitmentFormat => if (accept.nextLocalNonce_opt.isEmpty) return Left(MissingNonce(open.temporaryChannelId, TxId(ByteVector32.Zeroes))) + case _: SimpleTaprootChannelCommitmentFormat => if (accept.commitNonce_opt.isEmpty) return Left(MissingCommitNonce(open.temporaryChannelId, TxId(ByteVector32.Zeroes), commitmentNumber = 0)) case _: AnchorOutputsCommitmentFormat | DefaultCommitmentFormat => () } extractShutdownScript(accept.temporaryChannelId, localFeatures, remoteFeatures, accept.upfrontShutdownScript_opt).map(script_opt => (channelFeatures, script_opt)) @@ -290,10 +290,6 @@ object Helpers { } } - def hasMissingNonce(channelReestablish: ChannelReestablish, commitments: Commitments): Option[TxId] = { - commitments.active.find(c => c.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] && !channelReestablish.nextLocalNonces.contains(c.fundingTxId)).map(_.fundingTxId) - } - /** * @param remoteFeeratePerKw remote fee rate per kiloweight * @return true if the remote fee rate is too small @@ -560,7 +556,7 @@ object Helpers { channelId = commitments.channelId, perCommitmentSecret = localPerCommitmentSecret, nextPerCommitmentPoint = localNextPerCommitmentPoint, - nextLocalNonces = localCommitNonces, + nextCommitNonces = localCommitNonces, ) checkRemoteCommit(remoteChannelReestablish, retransmitRevocation_opt = Some(revocation)) } else if (commitments.localCommitIndex > remoteChannelReestablish.nextRemoteRevocationNumber + 1) { @@ -584,6 +580,12 @@ object Helpers { } } + def checkCommitNonces(channelReestablish: ChannelReestablish, commitments: Commitments): Option[ChannelException] = { + commitments.active + .find(c => c.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] && !channelReestablish.nextCommitNonces.contains(c.fundingTxId)) + .map(c => MissingCommitNonce(commitments.channelId, c.fundingTxId, c.remoteCommit.index + 1)) + } + } object Closing { @@ -783,7 +785,7 @@ object Helpers { val tlvs: TlvStream[ClosingCompleteTlv] = commitment.commitmentFormat match { case _: SimpleTaprootChannelCommitmentFormat => remoteNonce_opt match { - case None => return Left(MissingShutdownNonce(commitment.channelId)) + case None => return Left(MissingClosingNonce(commitment.channelId)) case Some(remoteNonce) => // If we cannot create our partial signature for one of our closing txs, we just skip it. // It will only happen if our peer sent an invalid nonce, in which case we cannot do anything anyway @@ -821,7 +823,7 @@ object Helpers { // Note that we're the closee, so we look for signatures including the closee output. commitment.commitmentFormat match { case _: SimpleTaprootChannelCommitmentFormat => localNonce_opt match { - case None => Left(MissingShutdownNonce(commitment.channelId)) + case None => Left(MissingClosingNonce(commitment.channelId)) case Some(localNonce) => (closingTxs.localAndRemote_opt, closingTxs.localOnly_opt) match { case (Some(_), Some(_)) if closingComplete.closerAndCloseeOutputsPartialSig_opt.isEmpty && closingComplete.closeeOutputOnlyPartialSig_opt.isEmpty => return Left(MissingCloseSignature(commitment.channelId)) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala index 6bc3cbdd93..e4dfb68b9b 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala @@ -230,11 +230,6 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall // Closer nonces are randomly generated when sending our closing_complete. var localCloserNonces_opt: Option[CloserNonces] = None - def setRemoteNextLocalNonces(info: String, n: Map[TxId, IndividualNonce]): Unit = { - remoteNextCommitNonces = n - log.debug("{} set remoteNextLocalNonces to {}", info, this.remoteNextCommitNonces) - } - // we pass these to helpers classes so that they have the logging context implicit def implicitLog: akka.event.DiagnosticLoggingAdapter = diagLog @@ -732,7 +727,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall d.commitments.receiveRevocation(revocation, nodeParams.onChainFeeConf.feerateToleranceFor(remoteNodeId).dustTolerance.maxExposure) match { case Right((commitments1, actions)) => cancelTimer(RevocationTimeout.toString) - setRemoteNextLocalNonces("received RevokeAndAck", revocation.nextLocalNonces) + remoteNextCommitNonces = revocation.nextCommitNonces log.debug("received a new rev, spec:\n{}", commitments1.latest.specs2String) actions.foreach { case PostRevocationAction.RelayHtlc(add) => @@ -824,7 +819,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall // in the meantime we won't send new changes stay() using d.copy(remoteShutdown = Some(remoteShutdown), closeStatus_opt = Some(CloseStatus.NonInitiator(None))) } else if (d.commitments.latest.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] && remoteShutdown.closeeNonce_opt.isEmpty) { - handleLocalError(MissingShutdownNonce(d.channelId), d, Some(remoteShutdown)) + handleLocalError(MissingClosingNonce(d.channelId), d, Some(remoteShutdown)) } else { remoteCloseeNonce_opt = remoteShutdown.closeeNonce_opt // so we don't have any unsigned outgoing changes @@ -1394,9 +1389,9 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall log.info("ignoring outgoing interactive-tx message {} from previous session", msg.getClass.getSimpleName) stay() } - case InteractiveTxBuilder.Succeeded(signingSession, commitSig, liquidityPurchase_opt, nextRemoteNonce_opt) => + case InteractiveTxBuilder.Succeeded(signingSession, commitSig, liquidityPurchase_opt, nextRemoteCommitNonce_opt) => log.info(s"splice tx created with fundingTxIndex=${signingSession.fundingTxIndex} fundingTxId=${signingSession.fundingTx.txId}") - nextRemoteNonce_opt.foreach { case (t, n) => setRemoteNextLocalNonces("swap completed", remoteNextCommitNonces + (t -> n)) } + nextRemoteCommitNonce_opt.foreach { case (txId, nonce) => remoteNextCommitNonces = remoteNextCommitNonces + (txId -> nonce) } cmd_opt.foreach(cmd => cmd.replyTo ! RES_SPLICE(fundingTxIndex = signingSession.fundingTxIndex, signingSession.fundingTx.txId, signingSession.fundingParams.fundingAmount, signingSession.localCommit.fold(_.spec, _.spec).toLocal)) remoteCommitSig_opt.foreach(self ! _) liquidityPurchase_opt.collect { @@ -2523,277 +2518,290 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall }) when(SYNCING)(handleExceptions { - case Event(channelReestablish: ChannelReestablish, d: DATA_WAIT_FOR_FUNDING_CONFIRMED) if Helpers.hasMissingNonce(channelReestablish, d.commitments).isDefined => - handleLocalError(MissingNonce(d.channelId, Helpers.hasMissingNonce(channelReestablish, d.commitments).get), d, Some(channelReestablish)) - - case Event(channelReestablish: ChannelReestablish, _: DATA_WAIT_FOR_FUNDING_CONFIRMED) => - setRemoteNextLocalNonces("received ChannelReestablish", channelReestablish.nextLocalNonces) - goto(WAIT_FOR_FUNDING_CONFIRMED) - - case Event(channelReestablish: ChannelReestablish, d: DATA_WAIT_FOR_DUAL_FUNDING_SIGNED) if d.signingSession.fundingParams.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] && channelReestablish.nextLocalNonces.isEmpty => - handleLocalError(MissingNonce(d.channelId, d.signingSession.fundingTx.txId), d, Some(channelReestablish)) + case Event(channelReestablish: ChannelReestablish, d: DATA_WAIT_FOR_FUNDING_CONFIRMED) => + Helpers.Syncing.checkCommitNonces(channelReestablish, d.commitments) match { + case Some(f) => handleLocalError(f, d, Some(channelReestablish)) + case None => + remoteNextCommitNonces = channelReestablish.nextCommitNonces + goto(WAIT_FOR_FUNDING_CONFIRMED) + } case Event(channelReestablish: ChannelReestablish, d: DATA_WAIT_FOR_DUAL_FUNDING_SIGNED) => - setRemoteNextLocalNonces("received ChannelReestablish", channelReestablish.nextLocalNonces) - channelReestablish.nextFundingTxId_opt match { - case Some(fundingTxId) if fundingTxId == d.signingSession.fundingTx.txId && channelReestablish.nextLocalCommitmentNumber == 0 => - // They haven't received our commit_sig: we retransmit it, and will send our tx_signatures once we've received - // their commit_sig or their tx_signatures (depending on who must send tx_signatures first). - val fundingParams = d.signingSession.fundingParams - val remoteNonce_opt = channelReestablish.currentCommitNonce_opt // remoteNextLocalNonces.get(d.signingSession.fundingTx.txId) - d.signingSession.remoteCommit.sign(d.channelParams, d.signingSession.remoteCommitParams, channelKeys, d.signingSession.fundingTxIndex, fundingParams.remoteFundingPubKey, d.signingSession.commitInput(channelKeys), fundingParams.commitmentFormat, remoteNonce_opt) match { - case Left(e) => handleLocalError(e, d, None) - case Right(commitSig) => goto(WAIT_FOR_DUAL_FUNDING_SIGNED) sending commitSig + d.signingSession.fundingParams.commitmentFormat match { + case _: SimpleTaprootChannelCommitmentFormat if !channelReestablish.nextCommitNonces.contains(d.signingSession.fundingTxId) => + val f = MissingCommitNonce(d.channelId, d.signingSession.fundingTxId, commitmentNumber = 0) + handleLocalError(f, d, Some(channelReestablish)) + case _ => + remoteNextCommitNonces = channelReestablish.nextCommitNonces + channelReestablish.nextFundingTxId_opt match { + case Some(fundingTxId) if fundingTxId == d.signingSession.fundingTx.txId && channelReestablish.nextLocalCommitmentNumber == 0 => + // They haven't received our commit_sig: we retransmit it, and will send our tx_signatures once we've received + // their commit_sig or their tx_signatures (depending on who must send tx_signatures first). + val fundingParams = d.signingSession.fundingParams + val remoteNonce_opt = channelReestablish.currentCommitNonce_opt + d.signingSession.remoteCommit.sign(d.channelParams, d.signingSession.remoteCommitParams, channelKeys, d.signingSession.fundingTxIndex, fundingParams.remoteFundingPubKey, d.signingSession.commitInput(channelKeys), fundingParams.commitmentFormat, remoteNonce_opt) match { + case Left(e) => handleLocalError(e, d, Some(channelReestablish)) + case Right(commitSig) => goto(WAIT_FOR_DUAL_FUNDING_SIGNED) sending commitSig + } + case _ => goto(WAIT_FOR_DUAL_FUNDING_SIGNED) } - case _ => goto(WAIT_FOR_DUAL_FUNDING_SIGNED) } - case Event(channelReestablish: ChannelReestablish, d: DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED) if Helpers.hasMissingNonce(channelReestablish, d.commitments).isDefined => - handleLocalError(MissingNonce(d.channelId, Helpers.hasMissingNonce(channelReestablish, d.commitments).get), d, Some(channelReestablish)) - case Event(channelReestablish: ChannelReestablish, d: DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED) => - setRemoteNextLocalNonces("received ChannelReestablish", channelReestablish.nextLocalNonces) - channelReestablish.nextFundingTxId_opt match { - case Some(fundingTxId) => - d.status match { - case DualFundingStatus.RbfWaitingForSigs(signingSession) if signingSession.fundingTx.txId == fundingTxId => - if (channelReestablish.nextLocalCommitmentNumber == 0) { - // They haven't received our commit_sig: we retransmit it. - // We're also waiting for signatures from them, and will send our tx_signatures once we receive them. - val fundingParams = signingSession.fundingParams - signingSession.remoteCommit.sign(d.commitments.channelParams, signingSession.remoteCommitParams, channelKeys, signingSession.fundingTxIndex, signingSession.fundingParams.remoteFundingPubKey, signingSession.commitInput(channelKeys), fundingParams.commitmentFormat, remoteNextCommitNonces.get(signingSession.fundingTx.txId)) match { - case Left(e) => handleLocalError(e, d, None) - case Right(commitSig) => goto(WAIT_FOR_DUAL_FUNDING_CONFIRMED) sending commitSig - } - } else { - // They have already received our commit_sig, but we were waiting for them to send either commit_sig or - // tx_signatures first. We wait for their message before sending our tx_signatures. - goto(WAIT_FOR_DUAL_FUNDING_CONFIRMED) - } - case _ if d.latestFundingTx.sharedTx.txId == fundingTxId => - // We've already received their commit_sig and sent our tx_signatures. We retransmit our tx_signatures - // and our commit_sig if they haven't received it already. - if (channelReestablish.nextLocalCommitmentNumber == 0) { - val remoteNonce_opt = channelReestablish.currentCommitNonce_opt - d.commitments.latest.remoteCommit.sign(d.commitments.channelParams, d.commitments.latest.remoteCommitParams, channelKeys, d.commitments.latest.fundingTxIndex, d.commitments.latest.remoteFundingPubKey, d.commitments.latest.commitInput(channelKeys), d.commitments.latest.commitmentFormat, remoteNonce_opt) match { - case Left(e) => handleLocalError(e, d, None) - case Right(commitSig) => goto(WAIT_FOR_DUAL_FUNDING_CONFIRMED) sending Seq(commitSig, d.latestFundingTx.sharedTx.localSigs) - } - } else { - goto(WAIT_FOR_DUAL_FUNDING_CONFIRMED) sending d.latestFundingTx.sharedTx.localSigs + Helpers.Syncing.checkCommitNonces(channelReestablish, d.commitments) match { + case Some(f) => handleLocalError(f, d, Some(channelReestablish)) + case None => + remoteNextCommitNonces = channelReestablish.nextCommitNonces + channelReestablish.nextFundingTxId_opt match { + case Some(fundingTxId) => + d.status match { + case DualFundingStatus.RbfWaitingForSigs(signingSession) if signingSession.fundingTx.txId == fundingTxId => + if (channelReestablish.nextLocalCommitmentNumber == 0) { + // They haven't received our commit_sig: we retransmit it. + // We're also waiting for signatures from them, and will send our tx_signatures once we receive them. + val fundingParams = signingSession.fundingParams + val remoteNonce_opt = channelReestablish.currentCommitNonce_opt + signingSession.remoteCommit.sign(d.commitments.channelParams, signingSession.remoteCommitParams, channelKeys, signingSession.fundingTxIndex, signingSession.fundingParams.remoteFundingPubKey, signingSession.commitInput(channelKeys), fundingParams.commitmentFormat, remoteNonce_opt) match { + case Left(e) => handleLocalError(e, d, Some(channelReestablish)) + case Right(commitSig) => goto(WAIT_FOR_DUAL_FUNDING_CONFIRMED) sending commitSig + } + } else { + // They have already received our commit_sig, but we were waiting for them to send either commit_sig or + // tx_signatures first. We wait for their message before sending our tx_signatures. + goto(WAIT_FOR_DUAL_FUNDING_CONFIRMED) + } + case _ if d.latestFundingTx.sharedTx.txId == fundingTxId => + // We've already received their commit_sig and sent our tx_signatures. We retransmit our tx_signatures + // and our commit_sig if they haven't received it already. + if (channelReestablish.nextLocalCommitmentNumber == 0) { + val remoteNonce_opt = channelReestablish.currentCommitNonce_opt + d.commitments.latest.remoteCommit.sign(d.commitments.channelParams, d.commitments.latest.remoteCommitParams, channelKeys, d.commitments.latest.fundingTxIndex, d.commitments.latest.remoteFundingPubKey, d.commitments.latest.commitInput(channelKeys), d.commitments.latest.commitmentFormat, remoteNonce_opt) match { + case Left(e) => handleLocalError(e, d, Some(channelReestablish)) + case Right(commitSig) => goto(WAIT_FOR_DUAL_FUNDING_CONFIRMED) sending Seq(commitSig, d.latestFundingTx.sharedTx.localSigs) + } + } else { + goto(WAIT_FOR_DUAL_FUNDING_CONFIRMED) sending d.latestFundingTx.sharedTx.localSigs + } + case _ => + // The fundingTxId must be for an RBF attempt that we didn't store (we got disconnected before receiving + // their tx_complete): we tell them to abort that RBF attempt. + goto(WAIT_FOR_DUAL_FUNDING_CONFIRMED) using d.copy(status = DualFundingStatus.RbfAborted) sending TxAbort(d.channelId, RbfAttemptAborted(d.channelId).getMessage) } - case _ => - // The fundingTxId must be for an RBF attempt that we didn't store (we got disconnected before receiving - // their tx_complete): we tell them to abort that RBF attempt. - goto(WAIT_FOR_DUAL_FUNDING_CONFIRMED) using d.copy(status = DualFundingStatus.RbfAborted) sending TxAbort(d.channelId, RbfAttemptAborted(d.channelId).getMessage) + case None => goto(WAIT_FOR_DUAL_FUNDING_CONFIRMED) } - case None => goto(WAIT_FOR_DUAL_FUNDING_CONFIRMED) } - case Event(channelReestablish: ChannelReestablish, d: DATA_WAIT_FOR_CHANNEL_READY) if d.commitments.active.exists(c => c.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] && !channelReestablish.nextLocalNonces.contains(c.fundingTxId)) => - handleLocalError(MissingNonce(d.channelId, d.commitments.active.find(c => c.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] && !channelReestablish.nextLocalNonces.contains(c.fundingTxId)).get.fundingTxId), d, Some(channelReestablish)) - case Event(channelReestablish: ChannelReestablish, d: DATA_WAIT_FOR_CHANNEL_READY) => - setRemoteNextLocalNonces("received ChannelReestablish", channelReestablish.nextLocalNonces) - val channelReady = createChannelReady(d.aliases, d.commitments) - goto(WAIT_FOR_CHANNEL_READY) sending channelReady - - case Event(channelReestablish: ChannelReestablish, d: DATA_WAIT_FOR_DUAL_FUNDING_READY) if Helpers.hasMissingNonce(channelReestablish, d.commitments).isDefined => - handleLocalError(MissingNonce(d.channelId, Helpers.hasMissingNonce(channelReestablish, d.commitments).get), d, Some(channelReestablish)) + Helpers.Syncing.checkCommitNonces(channelReestablish, d.commitments) match { + case Some(f) => handleLocalError(f, d, Some(channelReestablish)) + case None => + remoteNextCommitNonces = channelReestablish.nextCommitNonces + val channelReady = createChannelReady(d.aliases, d.commitments) + goto(WAIT_FOR_CHANNEL_READY) sending channelReady + } case Event(channelReestablish: ChannelReestablish, d: DATA_WAIT_FOR_DUAL_FUNDING_READY) => - log.debug("re-sending channel_ready") - setRemoteNextLocalNonces("received ChannelReestablish", channelReestablish.nextLocalNonces) - val channelReady = createChannelReady(d.aliases, d.commitments) - // We've already received their commit_sig and sent our tx_signatures. We retransmit our tx_signatures - // and our commit_sig if they haven't received it already. - channelReestablish.nextFundingTxId_opt match { - case Some(fundingTxId) if fundingTxId == d.commitments.latest.fundingTxId => - d.commitments.latest.localFundingStatus.localSigs_opt match { - case Some(txSigs) if channelReestablish.nextLocalCommitmentNumber == 0 => - log.info("re-sending commit_sig and tx_signatures for fundingTxIndex={} fundingTxId={}", d.commitments.latest.fundingTxIndex, d.commitments.latest.fundingTxId) - val remoteNonce_opt = channelReestablish.currentCommitNonce_opt - d.commitments.latest.remoteCommit.sign(d.commitments.channelParams, d.commitments.latest.remoteCommitParams, channelKeys, d.commitments.latest.fundingTxIndex, d.commitments.latest.remoteFundingPubKey, d.commitments.latest.commitInput(channelKeys), d.commitments.latest.commitmentFormat, remoteNonce_opt) match { - case Left(e) => handleLocalError(e, d, None) - case Right(commitSig) => goto(WAIT_FOR_DUAL_FUNDING_READY) sending Seq(commitSig, txSigs, channelReady) + Helpers.Syncing.checkCommitNonces(channelReestablish, d.commitments) match { + case Some(f) => handleLocalError(f, d, Some(channelReestablish)) + case None => + remoteNextCommitNonces = channelReestablish.nextCommitNonces + val channelReady = createChannelReady(d.aliases, d.commitments) + // We've already received their commit_sig and sent our tx_signatures. We retransmit our tx_signatures + // and our commit_sig if they haven't received it already. + channelReestablish.nextFundingTxId_opt match { + case Some(fundingTxId) if fundingTxId == d.commitments.latest.fundingTxId => + d.commitments.latest.localFundingStatus.localSigs_opt match { + case Some(txSigs) if channelReestablish.nextLocalCommitmentNumber == 0 => + log.info("re-sending commit_sig and tx_signatures for fundingTxIndex={} fundingTxId={}", d.commitments.latest.fundingTxIndex, d.commitments.latest.fundingTxId) + val remoteNonce_opt = channelReestablish.currentCommitNonce_opt + d.commitments.latest.remoteCommit.sign(d.commitments.channelParams, d.commitments.latest.remoteCommitParams, channelKeys, d.commitments.latest.fundingTxIndex, d.commitments.latest.remoteFundingPubKey, d.commitments.latest.commitInput(channelKeys), d.commitments.latest.commitmentFormat, remoteNonce_opt) match { + case Left(e) => handleLocalError(e, d, Some(channelReestablish)) + case Right(commitSig) => goto(WAIT_FOR_DUAL_FUNDING_READY) sending Seq(commitSig, txSigs, channelReady) + } + case Some(txSigs) => + log.info("re-sending tx_signatures for fundingTxIndex={} fundingTxId={}", d.commitments.latest.fundingTxIndex, d.commitments.latest.fundingTxId) + goto(WAIT_FOR_DUAL_FUNDING_READY) sending Seq(txSigs, channelReady) + case None => + log.warning("cannot retransmit tx_signatures, we don't have them (status={})", d.commitments.latest.localFundingStatus) + goto(WAIT_FOR_DUAL_FUNDING_READY) sending channelReady } - case Some(txSigs) => - log.info("re-sending tx_signatures for fundingTxIndex={} fundingTxId={}", d.commitments.latest.fundingTxIndex, d.commitments.latest.fundingTxId) - goto(WAIT_FOR_DUAL_FUNDING_READY) sending Seq(txSigs, channelReady) - case None => - log.warning("cannot retransmit tx_signatures, we don't have them (status={})", d.commitments.latest.localFundingStatus) - goto(WAIT_FOR_DUAL_FUNDING_READY) sending channelReady + case _ => goto(WAIT_FOR_DUAL_FUNDING_READY) sending channelReady } - case _ => goto(WAIT_FOR_DUAL_FUNDING_READY) sending channelReady } case Event(channelReestablish: ChannelReestablish, d: DATA_NORMAL) => - setRemoteNextLocalNonces("received ChannelReestablish", channelReestablish.nextLocalNonces) - Syncing.checkSync(channelKeys, d.commitments, channelReestablish) match { - case syncFailure: SyncResult.Failure => - handleSyncFailure(channelReestablish, syncFailure, d) - case syncSuccess: SyncResult.Success => - var sendQueue = Queue.empty[LightningMessage] - // normal case, our data is up-to-date - - // re-send channel_ready and announcement_signatures if necessary - d.commitments.lastLocalLocked_opt match { - case None => () - // We only send channel_ready for initial funding transactions. - case Some(c) if c.fundingTxIndex != 0 => () - case Some(c) => - val remoteSpliceSupport = d.commitments.remoteChannelParams.initFeatures.hasFeature(Features.SplicePrototype) - // If our peer has not received our channel_ready, we retransmit it. - val notReceivedByRemote = remoteSpliceSupport && channelReestablish.yourLastFundingLocked_opt.isEmpty - // If next_local_commitment_number is 1 in both the channel_reestablish it sent and received, then the node - // MUST retransmit channel_ready, otherwise it MUST NOT - val notReceivedByRemoteLegacy = !remoteSpliceSupport && channelReestablish.nextLocalCommitmentNumber == 1 && c.localCommit.index == 0 - // If this is a public channel and we haven't announced the channel, we retransmit our channel_ready and - // will also send announcement_signatures. - val notAnnouncedYet = d.commitments.announceChannel && c.shortChannelId_opt.nonEmpty && d.lastAnnouncement_opt.isEmpty - if (notAnnouncedYet || notReceivedByRemote || notReceivedByRemoteLegacy) { - log.debug("re-sending channel_ready") - sendQueue = sendQueue :+ createChannelReady(d.aliases, d.commitments) - } - if (notAnnouncedYet) { - // The funding transaction is confirmed, so we've already sent our announcement_signatures. - // We haven't announced the channel yet, which means we haven't received our peer's announcement_signatures. - // We retransmit our announcement_signatures to let our peer know that we're ready to announce the channel. - val localAnnSigs = c.signAnnouncement(nodeParams, d.commitments.channelParams, channelKeys.fundingKey(c.fundingTxIndex)) - localAnnSigs.foreach(annSigs => { - announcementSigsSent += annSigs.shortChannelId - sendQueue = sendQueue :+ annSigs - }) + Helpers.Syncing.checkCommitNonces(channelReestablish, d.commitments) match { + case Some(f) => handleLocalError(f, d, Some(channelReestablish)) + case None => + remoteNextCommitNonces = channelReestablish.nextCommitNonces + Syncing.checkSync(channelKeys, d.commitments, channelReestablish) match { + case syncFailure: SyncResult.Failure => + handleSyncFailure(channelReestablish, syncFailure, d) + case syncSuccess: SyncResult.Success => + var sendQueue = Queue.empty[LightningMessage] + // normal case, our data is up-to-date + + // re-send channel_ready and announcement_signatures if necessary + d.commitments.lastLocalLocked_opt match { + case None => () + // We only send channel_ready for initial funding transactions. + case Some(c) if c.fundingTxIndex != 0 => () + case Some(c) => + val remoteSpliceSupport = d.commitments.remoteChannelParams.initFeatures.hasFeature(Features.SplicePrototype) + // If our peer has not received our channel_ready, we retransmit it. + val notReceivedByRemote = remoteSpliceSupport && channelReestablish.yourLastFundingLocked_opt.isEmpty + // If next_local_commitment_number is 1 in both the channel_reestablish it sent and received, then the node + // MUST retransmit channel_ready, otherwise it MUST NOT + val notReceivedByRemoteLegacy = !remoteSpliceSupport && channelReestablish.nextLocalCommitmentNumber == 1 && c.localCommit.index == 0 + // If this is a public channel and we haven't announced the channel, we retransmit our channel_ready and + // will also send announcement_signatures. + val notAnnouncedYet = d.commitments.announceChannel && c.shortChannelId_opt.nonEmpty && d.lastAnnouncement_opt.isEmpty + if (notAnnouncedYet || notReceivedByRemote || notReceivedByRemoteLegacy) { + log.debug("re-sending channel_ready") + sendQueue = sendQueue :+ createChannelReady(d.aliases, d.commitments) + } + if (notAnnouncedYet) { + // The funding transaction is confirmed, so we've already sent our announcement_signatures. + // We haven't announced the channel yet, which means we haven't received our peer's announcement_signatures. + // We retransmit our announcement_signatures to let our peer know that we're ready to announce the channel. + val localAnnSigs = c.signAnnouncement(nodeParams, d.commitments.channelParams, channelKeys.fundingKey(c.fundingTxIndex)) + localAnnSigs.foreach(annSigs => { + announcementSigsSent += annSigs.shortChannelId + sendQueue = sendQueue :+ annSigs + }) + } } - } - // resume splice signing session if any - val spliceStatus1 = channelReestablish.nextFundingTxId_opt match { - case Some(fundingTxId) => - d.spliceStatus match { - case SpliceStatus.SpliceWaitingForSigs(signingSession) if signingSession.fundingTx.txId == fundingTxId => - if (channelReestablish.nextLocalCommitmentNumber == d.commitments.remoteCommitIndex) { - // They haven't received our commit_sig: we retransmit it. - // We're also waiting for signatures from them, and will send our tx_signatures once we receive them. - log.info("re-sending commit_sig for splice attempt with fundingTxIndex={} fundingTxId={}", signingSession.fundingTxIndex, signingSession.fundingTx.txId) - val fundingParams = signingSession.fundingParams - signingSession.remoteCommit.sign(d.commitments.channelParams, signingSession.remoteCommitParams, channelKeys, signingSession.fundingTxIndex, fundingParams.remoteFundingPubKey, signingSession.commitInput(channelKeys), fundingParams.commitmentFormat, channelReestablish.currentCommitNonce_opt) match { - case Left(channelException: ChannelException) => throw channelException // our exception handler will call handleLocalError() which understands ChannelException and will close the channel - case Right(commitSig) => sendQueue = sendQueue :+ commitSig - } - } - d.spliceStatus - case _ if d.commitments.latest.fundingTxId == fundingTxId => - d.commitments.latest.localFundingStatus match { - case dfu: LocalFundingStatus.DualFundedUnconfirmedFundingTx => - // We've already received their commit_sig and sent our tx_signatures. We retransmit our - // tx_signatures and our commit_sig if they haven't received it already. + // resume splice signing session if any + val spliceStatus1 = channelReestablish.nextFundingTxId_opt match { + case Some(fundingTxId) => + d.spliceStatus match { + case SpliceStatus.SpliceWaitingForSigs(signingSession) if signingSession.fundingTx.txId == fundingTxId => if (channelReestablish.nextLocalCommitmentNumber == d.commitments.remoteCommitIndex) { - log.info("re-sending commit_sig and tx_signatures for fundingTxIndex={} fundingTxId={}", d.commitments.latest.fundingTxIndex, d.commitments.latest.fundingTxId) - d.commitments.latest.remoteCommit.sign(d.commitments.channelParams, d.commitments.latest.remoteCommitParams, channelKeys, d.commitments.latest.fundingTxIndex, d.commitments.latest.remoteFundingPubKey, d.commitments.latest.commitInput(channelKeys), d.commitments.latest.commitmentFormat, channelReestablish.currentCommitNonce_opt) match { - case Left(channelException: ChannelException) => throw channelException // our exception handler will call handleLocalError() which understands ChannelException and will close the channel - case Right(commitSig) => sendQueue = sendQueue :+ commitSig :+ dfu.sharedTx.localSigs + // They haven't received our commit_sig: we retransmit it. + // We're also waiting for signatures from them, and will send our tx_signatures once we receive them. + log.info("re-sending commit_sig for splice attempt with fundingTxIndex={} fundingTxId={}", signingSession.fundingTxIndex, signingSession.fundingTx.txId) + val fundingParams = signingSession.fundingParams + val remoteNonce_opt = channelReestablish.currentCommitNonce_opt + signingSession.remoteCommit.sign(d.commitments.channelParams, signingSession.remoteCommitParams, channelKeys, signingSession.fundingTxIndex, fundingParams.remoteFundingPubKey, signingSession.commitInput(channelKeys), fundingParams.commitmentFormat, remoteNonce_opt) match { + case Left(f) => sendQueue = sendQueue :+ Warning(d.channelId, f.getMessage) + case Right(commitSig) => sendQueue = sendQueue :+ commitSig } - } else { - log.info("re-sending tx_signatures for fundingTxIndex={} fundingTxId={}", d.commitments.latest.fundingTxIndex, d.commitments.latest.fundingTxId) - sendQueue = sendQueue :+ dfu.sharedTx.localSigs } - case fundingStatus => - // They have not received our tx_signatures, but they must have received our commit_sig, otherwise we would be in the case above. - log.info("re-sending tx_signatures for fundingTxIndex={} fundingTxId={} (already published or confirmed)", d.commitments.latest.fundingTxIndex, d.commitments.latest.fundingTxId) - sendQueue = sendQueue ++ fundingStatus.localSigs_opt.toSeq + d.spliceStatus + case _ if d.commitments.latest.fundingTxId == fundingTxId => + d.commitments.latest.localFundingStatus match { + case dfu: LocalFundingStatus.DualFundedUnconfirmedFundingTx => + // We've already received their commit_sig and sent our tx_signatures. We retransmit our + // tx_signatures and our commit_sig if they haven't received it already. + if (channelReestablish.nextLocalCommitmentNumber == d.commitments.remoteCommitIndex) { + log.info("re-sending commit_sig and tx_signatures for fundingTxIndex={} fundingTxId={}", d.commitments.latest.fundingTxIndex, d.commitments.latest.fundingTxId) + val remoteNonce_opt = channelReestablish.currentCommitNonce_opt + d.commitments.latest.remoteCommit.sign(d.commitments.channelParams, d.commitments.latest.remoteCommitParams, channelKeys, d.commitments.latest.fundingTxIndex, d.commitments.latest.remoteFundingPubKey, d.commitments.latest.commitInput(channelKeys), d.commitments.latest.commitmentFormat, remoteNonce_opt) match { + case Left(f) => sendQueue = sendQueue :+ Warning(d.channelId, f.getMessage) + case Right(commitSig) => sendQueue = sendQueue :+ commitSig :+ dfu.sharedTx.localSigs + } + } else { + log.info("re-sending tx_signatures for fundingTxIndex={} fundingTxId={}", d.commitments.latest.fundingTxIndex, d.commitments.latest.fundingTxId) + sendQueue = sendQueue :+ dfu.sharedTx.localSigs + } + case fundingStatus => + // They have not received our tx_signatures, but they must have received our commit_sig, otherwise we would be in the case above. + log.info("re-sending tx_signatures for fundingTxIndex={} fundingTxId={} (already published or confirmed)", d.commitments.latest.fundingTxIndex, d.commitments.latest.fundingTxId) + sendQueue = sendQueue ++ fundingStatus.localSigs_opt.toSeq + } + d.spliceStatus + case _ => + // The fundingTxId must be for a splice attempt that we didn't store (we got disconnected before receiving + // their tx_complete): we tell them to abort that splice attempt. + log.info(s"aborting obsolete splice attempt for fundingTxId=$fundingTxId") + sendQueue = sendQueue :+ TxAbort(d.channelId, SpliceAttemptAborted(d.channelId).getMessage) + SpliceStatus.SpliceAborted } - d.spliceStatus - case _ => - // The fundingTxId must be for a splice attempt that we didn't store (we got disconnected before receiving - // their tx_complete): we tell them to abort that splice attempt. - log.info(s"aborting obsolete splice attempt for fundingTxId=$fundingTxId") - sendQueue = sendQueue :+ TxAbort(d.channelId, SpliceAttemptAborted(d.channelId).getMessage) - SpliceStatus.SpliceAborted + case None => d.spliceStatus } - case None => d.spliceStatus - } - // Prune previous funding transactions and RBF attempts if we already sent splice_locked for the last funding - // transaction that is also locked by our counterparty; we either missed their splice_locked or it confirmed - // while disconnected. - val commitments1: Commitments = channelReestablish.myCurrentFundingLocked_opt - .flatMap(remoteFundingTxLocked => d.commitments.updateRemoteFundingStatus(remoteFundingTxLocked, d.lastAnnouncedFundingTxId_opt).toOption.map(_._1)) - .getOrElse(d.commitments) - // We then clean up unsigned updates that haven't been received before the disconnection. - .discardUnsignedUpdates() - - commitments1.lastLocalLocked_opt match { - case None => () - // We only send splice_locked for splice transactions. - case Some(c) if c.fundingTxIndex == 0 => () - case Some(c) => - // If our peer has not received our splice_locked, we retransmit it. - val notReceivedByRemote = !channelReestablish.yourLastFundingLocked_opt.contains(c.fundingTxId) - // If this is a public channel and we haven't announced the splice, we retransmit our splice_locked and - // will exchange announcement_signatures afterwards. - val notAnnouncedYet = commitments1.announceChannel && d.lastAnnouncement_opt.forall(ann => !c.shortChannelId_opt.contains(ann.shortChannelId)) - if (notReceivedByRemote || notAnnouncedYet) { - // Retransmission of local announcement_signatures for splices are done when receiving splice_locked, no need - // to retransmit here. - log.debug("re-sending splice_locked for fundingTxId={}", c.fundingTxId) - spliceLockedSent += (c.fundingTxId -> c.fundingTxIndex) - trimSpliceLockedSentIfNeeded() - sendQueue = sendQueue :+ SpliceLocked(d.channelId, c.fundingTxId) + // Prune previous funding transactions and RBF attempts if we already sent splice_locked for the last funding + // transaction that is also locked by our counterparty; we either missed their splice_locked or it confirmed + // while disconnected. + val commitments1: Commitments = channelReestablish.myCurrentFundingLocked_opt + .flatMap(remoteFundingTxLocked => d.commitments.updateRemoteFundingStatus(remoteFundingTxLocked, d.lastAnnouncedFundingTxId_opt).toOption.map(_._1)) + .getOrElse(d.commitments) + // We then clean up unsigned updates that haven't been received before the disconnection. + .discardUnsignedUpdates() + + commitments1.lastLocalLocked_opt match { + case None => () + // We only send splice_locked for splice transactions. + case Some(c) if c.fundingTxIndex == 0 => () + case Some(c) => + // If our peer has not received our splice_locked, we retransmit it. + val notReceivedByRemote = !channelReestablish.yourLastFundingLocked_opt.contains(c.fundingTxId) + // If this is a public channel and we haven't announced the splice, we retransmit our splice_locked and + // will exchange announcement_signatures afterwards. + val notAnnouncedYet = commitments1.announceChannel && d.lastAnnouncement_opt.forall(ann => !c.shortChannelId_opt.contains(ann.shortChannelId)) + if (notReceivedByRemote || notAnnouncedYet) { + // Retransmission of local announcement_signatures for splices are done when receiving splice_locked, no need + // to retransmit here. + log.debug("re-sending splice_locked for fundingTxId={}", c.fundingTxId) + spliceLockedSent += (c.fundingTxId -> c.fundingTxIndex) + trimSpliceLockedSentIfNeeded() + sendQueue = sendQueue :+ SpliceLocked(d.channelId, c.fundingTxId) + } } - } - // we may need to retransmit updates and/or commit_sig and/or revocation - sendQueue = sendQueue ++ syncSuccess.retransmit + // we may need to retransmit updates and/or commit_sig and/or revocation + sendQueue = sendQueue ++ syncSuccess.retransmit - commitments1.remoteNextCommitInfo match { - case Left(_) => - // we expect them to (re-)send the revocation immediately - startSingleTimer(RevocationTimeout.toString, RevocationTimeout(commitments1.remoteCommitIndex, peer), nodeParams.channelConf.revocationTimeout) - case _ => () - } + commitments1.remoteNextCommitInfo match { + case Left(_) => + // we expect them to (re-)send the revocation immediately + startSingleTimer(RevocationTimeout.toString, RevocationTimeout(commitments1.remoteCommitIndex, peer), nodeParams.channelConf.revocationTimeout) + case _ => () + } - // do I have something to sign? - if (commitments1.changes.localHasChanges) { - self ! CMD_SIGN() - } + // do I have something to sign? + if (commitments1.changes.localHasChanges) { + self ! CMD_SIGN() + } - // BOLT 2: A node if it has sent a previous shutdown MUST retransmit shutdown. - d.localShutdown.foreach { - localShutdown => - log.debug("re-sending local_shutdown") - sendQueue = sendQueue :+ localShutdown - } + // BOLT 2: A node if it has sent a previous shutdown MUST retransmit shutdown. + d.localShutdown.foreach { + localShutdown => + log.debug("re-sending local_shutdown") + sendQueue = sendQueue :+ localShutdown + } - if (d.commitments.announceChannel) { - // we will re-enable the channel after some delay to prevent flappy updates in case the connection is unstable - startSingleTimer(Reconnected.toString, BroadcastChannelUpdate(Reconnected), 10 seconds) - } else { - // except for private channels where our peer is likely a mobile wallet: they will stay online only for a short period of time, - // so we need to re-enable them immediately to ensure we can route payments to them. It's also less of a problem to frequently - // refresh the channel update for private channels, since we won't broadcast it to the rest of the network. - self ! BroadcastChannelUpdate(Reconnected) - } + if (d.commitments.announceChannel) { + // we will re-enable the channel after some delay to prevent flappy updates in case the connection is unstable + startSingleTimer(Reconnected.toString, BroadcastChannelUpdate(Reconnected), 10 seconds) + } else { + // except for private channels where our peer is likely a mobile wallet: they will stay online only for a short period of time, + // so we need to re-enable them immediately to ensure we can route payments to them. It's also less of a problem to frequently + // refresh the channel update for private channels, since we won't broadcast it to the rest of the network. + self ! BroadcastChannelUpdate(Reconnected) + } - // We usually handle feerate updates once per block (~10 minutes), but when our remote is a mobile wallet that - // only briefly connects and then disconnects, we may never have the opportunity to send our `update_fee`, so - // we send it (if needed) when reconnected. - val shutdownInProgress = d.localShutdown.nonEmpty || d.remoteShutdown.nonEmpty - if (d.commitments.localChannelParams.paysCommitTxFees && !shutdownInProgress) { - val currentFeeratePerKw = d.commitments.latest.localCommit.spec.commitTxFeerate - val networkFeeratePerKw = nodeParams.onChainFeeConf.getCommitmentFeerate(nodeParams.currentBitcoinCoreFeerates, remoteNodeId, d.commitments.latest.commitmentFormat, d.commitments.latest.capacity) - if (nodeParams.onChainFeeConf.shouldUpdateFee(currentFeeratePerKw, networkFeeratePerKw)) { - self ! CMD_UPDATE_FEE(networkFeeratePerKw, commit = true) - } - } + // We usually handle feerate updates once per block (~10 minutes), but when our remote is a mobile wallet that + // only briefly connects and then disconnects, we may never have the opportunity to send our `update_fee`, so + // we send it (if needed) when reconnected. + val shutdownInProgress = d.localShutdown.nonEmpty || d.remoteShutdown.nonEmpty + if (d.commitments.localChannelParams.paysCommitTxFees && !shutdownInProgress) { + val currentFeeratePerKw = d.commitments.latest.localCommit.spec.commitTxFeerate + val networkFeeratePerKw = nodeParams.onChainFeeConf.getCommitmentFeerate(nodeParams.currentBitcoinCoreFeerates, remoteNodeId, d.commitments.latest.commitmentFormat, d.commitments.latest.capacity) + if (nodeParams.onChainFeeConf.shouldUpdateFee(currentFeeratePerKw, networkFeeratePerKw)) { + self ! CMD_UPDATE_FEE(networkFeeratePerKw, commit = true) + } + } - // We tell the peer that the channel is ready to process payments that may be queued. - if (!shutdownInProgress) { - val fundingTxIndex = commitments1.active.map(_.fundingTxIndex).min - peer ! ChannelReadyForPayments(self, remoteNodeId, d.channelId, fundingTxIndex) - } + // We tell the peer that the channel is ready to process payments that may be queued. + if (!shutdownInProgress) { + val fundingTxIndex = commitments1.active.map(_.fundingTxIndex).min + peer ! ChannelReadyForPayments(self, remoteNodeId, d.channelId, fundingTxIndex) + } - goto(NORMAL) using d.copy(commitments = commitments1, spliceStatus = spliceStatus1) sending sendQueue + goto(NORMAL) using d.copy(commitments = commitments1, spliceStatus = spliceStatus1) sending sendQueue + } } case Event(c: CMD_ADD_HTLC, d: DATA_NORMAL) => handleAddDisconnected(c, d) @@ -2808,19 +2816,20 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall case Event(c: CMD_UPDATE_RELAY_FEE, d: DATA_NORMAL) => handleUpdateRelayFeeDisconnected(c, d) - case Event(channelReestablish: ChannelReestablish, d: DATA_SHUTDOWN) if Helpers.hasMissingNonce(channelReestablish, d.commitments).isDefined => - handleLocalError(MissingNonce(d.channelId, Helpers.hasMissingNonce(channelReestablish, d.commitments).get), d, Some(channelReestablish)) - case Event(channelReestablish: ChannelReestablish, d: DATA_SHUTDOWN) => - setRemoteNextLocalNonces("received ChannelReestablish", channelReestablish.nextLocalNonces) - Syncing.checkSync(channelKeys, d.commitments, channelReestablish) match { - case syncFailure: SyncResult.Failure => - handleSyncFailure(channelReestablish, syncFailure, d) - case syncSuccess: SyncResult.Success => - val commitments1 = d.commitments.discardUnsignedUpdates() - val sendQueue = Queue.empty[LightningMessage] ++ syncSuccess.retransmit :+ d.localShutdown - // BOLT 2: A node if it has sent a previous shutdown MUST retransmit shutdown. - goto(SHUTDOWN) using d.copy(commitments = commitments1) sending sendQueue + Helpers.Syncing.checkCommitNonces(channelReestablish, d.commitments) match { + case Some(f) => handleLocalError(f, d, Some(channelReestablish)) + case None => + remoteNextCommitNonces = channelReestablish.nextCommitNonces + Syncing.checkSync(channelKeys, d.commitments, channelReestablish) match { + case syncFailure: SyncResult.Failure => + handleSyncFailure(channelReestablish, syncFailure, d) + case syncSuccess: SyncResult.Success => + val commitments1 = d.commitments.discardUnsignedUpdates() + val sendQueue = Queue.empty[LightningMessage] ++ syncSuccess.retransmit :+ d.localShutdown + // BOLT 2: A node if it has sent a previous shutdown MUST retransmit shutdown. + goto(SHUTDOWN) using d.copy(commitments = commitments1) sending sendQueue + } } case Event(_: ChannelReestablish, d: DATA_NEGOTIATING) => @@ -3110,7 +3119,6 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall } context.system.eventStream.publish(ChannelStateChanged(self, nextStateData.channelId, peer, remoteNodeId, state, nextState, commitments_opt)) } - if (nextState == CLOSED) { // channel is closed, scheduling this actor for self destruction context.system.scheduler.scheduleOnce(1 minute, self, Symbol("shutdown")) @@ -3230,12 +3238,16 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall } } - /** On disconnection we clear up stashes. */ + /** On disconnection we clear up temporary mutable state that applies to the previous connection. */ onTransition { case _ -> OFFLINE => announcementSigsStash = Map.empty announcementSigsSent = Set.empty spliceLockedSent = Map.empty[TxId, Long] + remoteNextCommitNonces = Map.empty + localCloseeNonce_opt = None + remoteCloseeNonce_opt = None + localCloserNonces_opt = None } /* diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scala index e0fe4641fb..3cc1fce723 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scala @@ -322,8 +322,8 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers { case Event(msg: InteractiveTxBuilder.Response, d: DATA_WAIT_FOR_DUAL_FUNDING_CREATED) => msg match { case InteractiveTxBuilder.SendMessage(_, msg) => stay() sending msg - case InteractiveTxBuilder.Succeeded(status, commitSig, liquidityPurchase_opt, nextRemoteNonce_opt) => - nextRemoteNonce_opt.foreach { case (t, n) => setRemoteNextLocalNonces("dual funding completed", remoteNextCommitNonces + (t -> n)) } + case InteractiveTxBuilder.Succeeded(status, commitSig, liquidityPurchase_opt, nextRemoteCommitNonce_opt) => + nextRemoteCommitNonce_opt.foreach { case (txId, nonce) => remoteNextCommitNonces = remoteNextCommitNonces + (txId -> nonce) } d.deferred.foreach(self ! _) d.replyTo_opt.foreach(_ ! OpenChannelResponse.Created(d.channelId, status.fundingTx.txId, status.fundingTx.tx.localFees.truncateToSatoshi)) liquidityPurchase_opt.collect { @@ -692,8 +692,8 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers { case DualFundingStatus.RbfInProgress(cmd_opt, _, remoteCommitSig_opt) => msg match { case InteractiveTxBuilder.SendMessage(_, msg) => stay() sending msg - case InteractiveTxBuilder.Succeeded(signingSession, commitSig, liquidityPurchase_opt, nextRemoteNonce_opt) => - nextRemoteNonce_opt.foreach { case (t, n) => setRemoteNextLocalNonces("rbf completed", remoteNextCommitNonces + (t -> n)) } + case InteractiveTxBuilder.Succeeded(signingSession, commitSig, liquidityPurchase_opt, nextRemoteCommitNonce_opt) => + nextRemoteCommitNonce_opt.foreach { case (txId, nonce) => remoteNextCommitNonces = remoteNextCommitNonces + (txId -> nonce) } cmd_opt.foreach(cmd => cmd.replyTo ! RES_BUMP_FUNDING_FEE(rbfIndex = d.previousFundingTxs.length, signingSession.fundingTx.txId, signingSession.fundingTx.tx.localFees.truncateToSatoshi)) remoteCommitSig_opt.foreach(self ! _) liquidityPurchase_opt.collect { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenSingleFunded.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenSingleFunded.scala index 9f7c2893f7..23651e6a3d 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenSingleFunded.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenSingleFunded.scala @@ -19,9 +19,10 @@ package fr.acinq.eclair.channel.fsm import akka.actor.Status import akka.actor.typed.scaladsl.adapter.actorRefAdapter import akka.pattern.pipe -import fr.acinq.bitcoin.scalacompat.{ByteVector64, SatoshiLong} +import fr.acinq.bitcoin.scalacompat.SatoshiLong import fr.acinq.eclair.blockchain.OnChainWallet.MakeFundingTxResponse import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ +import fr.acinq.eclair.channel.ChannelSpendSignature.{IndividualSignature, PartialSignatureWithNonce} import fr.acinq.eclair.channel.Helpers.Funding import fr.acinq.eclair.channel.LocalFundingStatus.SingleFundedUnconfirmedFundingTx import fr.acinq.eclair.channel._ @@ -154,7 +155,7 @@ trait ChannelOpenSingleFunded extends SingleFundingHandlers with ErrorHandlers { Some(ChannelTlv.ChannelTypeTlv(d.initFundee.channelType)), localNonce.map(n => ChannelTlv.NextLocalNonceTlv(n)) ).flatten[AcceptChannelTlv])) - setRemoteNextLocalNonces("from their open_channel", open.nextLocalNonce_opt.map(n => Map(NonceGenerator.dummyFundingTxId -> n)).getOrElse(Map.empty)) + remoteNextCommitNonces = open.commitNonce_opt.map(n => NonceGenerator.dummyFundingTxId -> n).toMap goto(WAIT_FOR_FUNDING_CREATED) using DATA_WAIT_FOR_FUNDING_CREATED(channelParams, d.initFundee.channelType, localCommitParams, remoteCommitParams, open.fundingSatoshis, open.pushMsat, open.feeratePerKw, open.fundingPubkey, open.firstPerCommitmentPoint) sending accept } @@ -188,7 +189,7 @@ trait ChannelOpenSingleFunded extends SingleFundingHandlers with ErrorHandlers { val channelParams = ChannelParams(d.initFunder.temporaryChannelId, d.initFunder.channelConfig, channelFeatures, d.initFunder.localChannelParams, remoteChannelParams, d.lastSent.channelFlags) val localCommitParams = CommitParams(d.initFunder.proposedCommitParams.localDustLimit, d.initFunder.proposedCommitParams.localHtlcMinimum, d.initFunder.proposedCommitParams.localMaxHtlcValueInFlight, d.initFunder.proposedCommitParams.localMaxAcceptedHtlcs, accept.toSelfDelay) val remoteCommitParams = CommitParams(accept.dustLimitSatoshis, accept.htlcMinimumMsat, accept.maxHtlcValueInFlightMsat, accept.maxAcceptedHtlcs, d.initFunder.proposedCommitParams.toRemoteDelay) - setRemoteNextLocalNonces("received AcceptChannel", accept.nextLocalNonce_opt.map(n => Map(NonceGenerator.dummyFundingTxId -> n)).getOrElse(Map.empty)) + remoteNextCommitNonces = accept.commitNonce_opt.map(n => NonceGenerator.dummyFundingTxId -> n).toMap goto(WAIT_FOR_FUNDING_INTERNAL) using DATA_WAIT_FOR_FUNDING_INTERNAL(channelParams, d.initFunder.channelType, localCommitParams, remoteCommitParams, d.initFunder.fundingAmount, d.initFunder.pushAmount_opt.getOrElse(0 msat), d.initFunder.commitTxFeerate, accept.fundingPubkey, accept.firstPerCommitmentPoint, d.initFunder.replyTo) } @@ -216,44 +217,36 @@ trait ChannelOpenSingleFunded extends SingleFundingHandlers with ErrorHandlers { val fundingKey = channelKeys.fundingKey(fundingTxIndex = 0) val localCommitmentKeys = LocalCommitmentKeys(d.channelParams, channelKeys, localCommitIndex = 0, d.commitmentFormat) val remoteCommitmentKeys = RemoteCommitmentKeys(d.channelParams, channelKeys, d.remoteFirstPerCommitmentPoint, d.commitmentFormat) - Funding.makeFirstCommitTxs(d.channelParams, d.localCommitParams, d.remoteCommitParams, localFundingAmount = d.fundingAmount, remoteFundingAmount = 0 sat, localPushAmount = d.pushAmount, remotePushAmount = 0 msat, d.commitTxFeerate, d.commitmentFormat, fundingTx.txid, fundingTxOutputIndex, fundingKey, d.remoteFundingPubKey, localCommitmentKeys, remoteCommitmentKeys) match { case Left(ex) => handleLocalError(ex, d, None) case Right((localSpec, localCommitTx, remoteSpec, remoteCommitTx)) => - require(fundingTx.txOut(fundingTxOutputIndex).publicKeyScript == localCommitTx.input.txOut.publicKeyScript, s"pubkey script mismatch!") + require(fundingTx.txOut(fundingTxOutputIndex).publicKeyScript == localCommitTx.input.txOut.publicKeyScript, "pubkey script mismatch!") val remoteCommit = RemoteCommit(0, remoteSpec, remoteCommitTx.tx.txid, d.remoteFirstPerCommitmentPoint) - // signature of their initial commitment tx that pays remote pushMsat - val fundingCreated = d.commitmentFormat match { + val localSigOfRemoteTx = d.commitmentFormat match { case _: SimpleTaprootChannelCommitmentFormat => val localNonce = NonceGenerator.verificationNonce(NonceGenerator.dummyFundingTxId, fundingKey, NonceGenerator.dummyRemoteFundingPubKey, 0) - if (!remoteNextCommitNonces.contains(NonceGenerator.dummyFundingTxId)) throw MissingNonce(d.channelId, NonceGenerator.dummyFundingTxId) - val psig = remoteCommitTx.partialSign(fundingKey, d.remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteNextCommitNonces(NonceGenerator.dummyFundingTxId))) match { - case Left(_) => throw InvalidNonce(d.channelId, NonceGenerator.dummyFundingTxId) - case Right(psig) => psig + remoteNextCommitNonces.get(NonceGenerator.dummyFundingTxId) match { + case Some(remoteNonce) => + remoteCommitTx.partialSign(fundingKey, d.remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteNonce)) match { + case Left(_) => Left(InvalidCommitNonce(d.channelId, NonceGenerator.dummyFundingTxId, commitmentNumber = 0)) + case Right(psig) => Right(psig) + } + case None => Left(MissingCommitNonce(d.channelId, NonceGenerator.dummyFundingTxId, commitmentNumber = 0)) } - FundingCreated( - temporaryChannelId = temporaryChannelId, - fundingTxId = fundingTx.txid, - fundingOutputIndex = fundingTxOutputIndex, - signature = ByteVector64.Zeroes, - tlvStream = TlvStream(ChannelTlv.PartialSignatureWithNonceTlv(psig)) - ) - case _: AnchorOutputsCommitmentFormat | DefaultCommitmentFormat => - val localSigOfRemoteTx = remoteCommitTx.sign(fundingKey, d.remoteFundingPubKey).sig - FundingCreated( - temporaryChannelId = temporaryChannelId, - fundingTxId = fundingTx.txid, - fundingOutputIndex = fundingTxOutputIndex, - signature = localSigOfRemoteTx - ) + case _: AnchorOutputsCommitmentFormat | DefaultCommitmentFormat => Right(remoteCommitTx.sign(fundingKey, d.remoteFundingPubKey)) + } + localSigOfRemoteTx match { + case Left(f) => handleLocalError(f, d, None) + case Right(localSig) => + val fundingCreated = FundingCreated(temporaryChannelId, fundingTx.txid, fundingTxOutputIndex, localSig) + val channelId = toLongId(fundingTx.txid, fundingTxOutputIndex) + val channelParams1 = d.channelParams.copy(channelId = channelId) + peer ! ChannelIdAssigned(self, remoteNodeId, temporaryChannelId, channelId) // we notify the peer asap so it knows how to route messages + txPublisher ! SetChannelId(remoteNodeId, channelId) + context.system.eventStream.publish(ChannelIdAssigned(self, remoteNodeId, temporaryChannelId, channelId)) + // NB: we don't send a ChannelSignatureSent for the first commit + goto(WAIT_FOR_FUNDING_SIGNED) using DATA_WAIT_FOR_FUNDING_SIGNED(channelParams1, d.channelType, d.localCommitParams, d.remoteCommitParams, d.remoteFundingPubKey, fundingTx, fundingTxFee, localSpec, localCommitTx, remoteCommit, fundingCreated, d.replyTo) sending fundingCreated } - val channelId = toLongId(fundingTx.txid, fundingTxOutputIndex) - val channelParams1 = d.channelParams.copy(channelId = channelId) - peer ! ChannelIdAssigned(self, remoteNodeId, temporaryChannelId, channelId) // we notify the peer asap so it knows how to route messages - txPublisher ! SetChannelId(remoteNodeId, channelId) - context.system.eventStream.publish(ChannelIdAssigned(self, remoteNodeId, temporaryChannelId, channelId)) - // NB: we don't send a ChannelSignatureSent for the first commit - goto(WAIT_FOR_FUNDING_SIGNED) using DATA_WAIT_FOR_FUNDING_SIGNED(channelParams1, d.channelType, d.localCommitParams, d.remoteCommitParams, d.remoteFundingPubKey, fundingTx, fundingTxFee, localSpec, localCommitTx, remoteCommit, fundingCreated, d.replyTo) sending fundingCreated } case Event(Status.Failure(t), d: DATA_WAIT_FOR_FUNDING_INTERNAL) => @@ -284,65 +277,68 @@ trait ChannelOpenSingleFunded extends SingleFundingHandlers with ErrorHandlers { val fundingKey = channelKeys.fundingKey(fundingTxIndex = 0) val localCommitmentKeys = LocalCommitmentKeys(d.channelParams, channelKeys, localCommitIndex = 0, d.commitmentFormat) val remoteCommitmentKeys = RemoteCommitmentKeys(d.channelParams, channelKeys, d.remoteFirstPerCommitmentPoint, d.commitmentFormat) - // they fund the channel with their funding tx, so the money is theirs (but we are paid pushMsat) Funding.makeFirstCommitTxs(d.channelParams, d.localCommitParams, d.remoteCommitParams, localFundingAmount = 0 sat, remoteFundingAmount = d.fundingAmount, localPushAmount = 0 msat, remotePushAmount = d.pushAmount, d.commitTxFeerate, d.commitmentFormat, fundingTxId, fundingTxOutputIndex, fundingKey, d.remoteFundingPubKey, localCommitmentKeys, remoteCommitmentKeys) match { - case Left(ex) => handleLocalError(ex, d, None) + case Left(ex) => handleLocalError(ex, d, Some(fc)) case Right((localSpec, localCommitTx, remoteSpec, remoteCommitTx)) => // check remote signature validity - val isRemoveSigValid = fc.sigOrPartialSig match { - case psig: ChannelSpendSignature.PartialSignatureWithNonce if d.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => + val isRemoteSigValid = fc.sigOrPartialSig match { + case psig: PartialSignatureWithNonce => val localNonce = NonceGenerator.verificationNonce(NonceGenerator.dummyFundingTxId, fundingKey, NonceGenerator.dummyRemoteFundingPubKey, 0) localCommitTx.checkRemotePartialSignature(fundingKey.publicKey, d.remoteFundingPubKey, psig, localNonce.publicNonce) - case sig: ChannelSpendSignature.IndividualSignature if !d.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => + case sig: IndividualSignature => localCommitTx.checkRemoteSig(fundingKey.publicKey, d.remoteFundingPubKey, sig) - case _ => false } - isRemoveSigValid match { - case false => handleLocalError(InvalidCommitmentSignature(temporaryChannelId, fundingTxId, commitmentNumber = 0, localCommitTx.tx), d, None) + isRemoteSigValid match { + case false => handleLocalError(InvalidCommitmentSignature(temporaryChannelId, fundingTxId, commitmentNumber = 0, localCommitTx.tx), d, Some(fc)) case true => val channelId = toLongId(fundingTxId, fundingTxOutputIndex) - val fundingSigned = d.commitmentFormat match { + val localSigOfRemoteTx = d.commitmentFormat match { case _: SimpleTaprootChannelCommitmentFormat => val localNonce = NonceGenerator.verificationNonce(NonceGenerator.dummyFundingTxId, fundingKey, NonceGenerator.dummyRemoteFundingPubKey, 0) - if (!remoteNextCommitNonces.contains(NonceGenerator.dummyFundingTxId)) throw MissingNonce(d.channelId, NonceGenerator.dummyFundingTxId) - val localPartialSigOfRemoteTx = remoteCommitTx.partialSign(fundingKey, d.remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteNextCommitNonces(NonceGenerator.dummyFundingTxId))) match { - case Left(_) => throw InvalidNonce(d.channelId, NonceGenerator.dummyFundingTxId) - case Right(psig) => psig + remoteNextCommitNonces.get(NonceGenerator.dummyFundingTxId) match { + case Some(remoteNonce) => + remoteCommitTx.partialSign(fundingKey, d.remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteNonce)) match { + case Left(_) => Left(InvalidCommitNonce(channelId, NonceGenerator.dummyFundingTxId, commitmentNumber = 0)) + case Right(psig) => Right(psig) + } + case None => Left(MissingCommitNonce(channelId, NonceGenerator.dummyFundingTxId, commitmentNumber = 0)) } - FundingSigned(channelId = channelId, signature = ByteVector64.Zeroes, TlvStream(ChannelTlv.PartialSignatureWithNonceTlv(localPartialSigOfRemoteTx))) - case _: AnchorOutputsCommitmentFormat | DefaultCommitmentFormat => - val localSigOfRemoteTx = remoteCommitTx.sign(fundingKey, d.remoteFundingPubKey).sig - FundingSigned(channelId = channelId, signature = localSigOfRemoteTx) + case _: AnchorOutputsCommitmentFormat | DefaultCommitmentFormat => Right(remoteCommitTx.sign(fundingKey, d.remoteFundingPubKey)) + } + localSigOfRemoteTx match { + case Left(f) => handleLocalError(f, d, Some(fc)) + case Right(localSig) => + val fundingSigned = FundingSigned(channelId, localSig) + val commitment = Commitment( + fundingTxIndex = 0, + firstRemoteCommitIndex = 0, + fundingInput = localCommitTx.input.outPoint, + fundingAmount = localCommitTx.input.txOut.amount, + remoteFundingPubKey = d.remoteFundingPubKey, + localFundingStatus = SingleFundedUnconfirmedFundingTx(None), + remoteFundingStatus = RemoteFundingStatus.NotLocked, + commitmentFormat = d.commitmentFormat, + localCommitParams = d.localCommitParams, + localCommit = LocalCommit(0, localSpec, localCommitTx.tx.txid, fc.sigOrPartialSig, htlcRemoteSigs = Nil), + remoteCommitParams = d.remoteCommitParams, + remoteCommit = RemoteCommit(0, remoteSpec, remoteCommitTx.tx.txid, d.remoteFirstPerCommitmentPoint), + nextRemoteCommit_opt = None) + val commitments = Commitments( + channelParams = d.channelParams.copy(channelId = channelId), + changes = CommitmentChanges.init(), + active = List(commitment), + remoteNextCommitInfo = Right(randomKey().publicKey), // we will receive their next per-commitment point in the next message, so we temporarily put a random byte array + remotePerCommitmentSecrets = ShaChain.init, + originChannels = Map.empty) + peer ! ChannelIdAssigned(self, remoteNodeId, temporaryChannelId, channelId) // we notify the peer asap so it knows how to route messages + txPublisher ! SetChannelId(remoteNodeId, channelId) + context.system.eventStream.publish(ChannelIdAssigned(self, remoteNodeId, temporaryChannelId, channelId)) + context.system.eventStream.publish(ChannelSignatureReceived(self, commitments)) + // NB: we don't send a ChannelSignatureSent for the first commit + log.info("waiting for them to publish the funding tx for channelId={} fundingTxid={}", channelId, commitment.fundingTxId) + watchFundingConfirmed(commitment.fundingTxId, d.channelParams.minDepth(nodeParams.channelConf.minDepth), delay_opt = None) + goto(WAIT_FOR_FUNDING_CONFIRMED) using DATA_WAIT_FOR_FUNDING_CONFIRMED(commitments, nodeParams.currentBlockHeight, None, Right(fundingSigned)) storing() sending fundingSigned } - val commitment = Commitment( - fundingTxIndex = 0, - firstRemoteCommitIndex = 0, - fundingInput = localCommitTx.input.outPoint, - fundingAmount = localCommitTx.input.txOut.amount, - remoteFundingPubKey = d.remoteFundingPubKey, - localFundingStatus = SingleFundedUnconfirmedFundingTx(None), - remoteFundingStatus = RemoteFundingStatus.NotLocked, - commitmentFormat = d.commitmentFormat, - localCommitParams = d.localCommitParams, - localCommit = LocalCommit(0, localSpec, localCommitTx.tx.txid, fc.sigOrPartialSig, htlcRemoteSigs = Nil), - remoteCommitParams = d.remoteCommitParams, - remoteCommit = RemoteCommit(0, remoteSpec, remoteCommitTx.tx.txid, d.remoteFirstPerCommitmentPoint), - nextRemoteCommit_opt = None) - val commitments = Commitments( - channelParams = d.channelParams.copy(channelId = channelId), - changes = CommitmentChanges.init(), - active = List(commitment), - remoteNextCommitInfo = Right(randomKey().publicKey), // we will receive their next per-commitment point in the next message, so we temporarily put a random byte array - remotePerCommitmentSecrets = ShaChain.init, - originChannels = Map.empty) - peer ! ChannelIdAssigned(self, remoteNodeId, temporaryChannelId, channelId) // we notify the peer asap so it knows how to route messages - txPublisher ! SetChannelId(remoteNodeId, channelId) - context.system.eventStream.publish(ChannelIdAssigned(self, remoteNodeId, temporaryChannelId, channelId)) - context.system.eventStream.publish(ChannelSignatureReceived(self, commitments)) - // NB: we don't send a ChannelSignatureSent for the first commit - log.info("waiting for them to publish the funding tx for channelId={} fundingTxid={}", channelId, commitment.fundingTxId) - watchFundingConfirmed(commitment.fundingTxId, d.channelParams.minDepth(nodeParams.channelConf.minDepth), delay_opt = None) - goto(WAIT_FOR_FUNDING_CONFIRMED) using DATA_WAIT_FOR_FUNDING_CONFIRMED(commitments, nodeParams.currentBlockHeight, None, Right(fundingSigned)) storing() sending fundingSigned } } @@ -357,15 +353,14 @@ trait ChannelOpenSingleFunded extends SingleFundingHandlers with ErrorHandlers { case Event(fundingSigned: FundingSigned, d: DATA_WAIT_FOR_FUNDING_SIGNED) => // we make sure that their sig checks out and that our first commit tx is spendable val fundingKey = channelKeys.fundingKey(fundingTxIndex = 0) - val isRemoveSigValid = fundingSigned.sigOrPartialSig match { - case psig: ChannelSpendSignature.PartialSignatureWithNonce if d.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => + val isRemoteSigValid = fundingSigned.sigOrPartialSig match { + case psig: PartialSignatureWithNonce => val localNonce = NonceGenerator.verificationNonce(NonceGenerator.dummyFundingTxId, fundingKey, NonceGenerator.dummyRemoteFundingPubKey, 0) d.localCommitTx.checkRemotePartialSignature(fundingKey.publicKey, d.remoteFundingPubKey, psig, localNonce.publicNonce) - case sig: ChannelSpendSignature.IndividualSignature if !d.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => + case sig: IndividualSignature => d.localCommitTx.checkRemoteSig(fundingKey.publicKey, d.remoteFundingPubKey, sig) - case _ => false } - isRemoveSigValid match { + isRemoteSigValid match { case false => // we rollback the funding tx, it will never be published wallet.rollback(d.fundingTx) 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 2f1a320e02..928fff3490 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 @@ -158,7 +158,7 @@ trait CommonFundingHandlers extends CommonHandlers { }, remoteNextCommitInfo = Right(channelReady.nextPerCommitmentPoint) ) - setRemoteNextLocalNonces("received ChannelReady", channelReady.nextLocalNonce_opt.map(n => commitments.latest.fundingTxId -> n).toMap) + channelReady.nextCommitNonce_opt.foreach(nonce => remoteNextCommitNonces = remoteNextCommitNonces + (commitments.latest.fundingTxId -> nonce)) peer ! ChannelReadyForPayments(self, remoteNodeId, commitments.channelId, fundingTxIndex = 0) DATA_NORMAL(commitments1, aliases1, None, initialChannelUpdate, SpliceStatus.NoSplice, None, None, None) } 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 4d83f3f545..5257f0c6a9 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 @@ -95,7 +95,7 @@ object InteractiveTxBuilder { sealed trait Response case class SendMessage(sessionId: ByteVector32, msg: LightningMessage) extends Response - case class Succeeded(signingSession: InteractiveTxSigningSession.WaitingForSigs, commitSig: CommitSig, liquidityPurchase_opt: Option[LiquidityAds.Purchase], nextRemoteNonce_opt: Option[(TxId, IndividualNonce)]) extends Response + case class Succeeded(signingSession: InteractiveTxSigningSession.WaitingForSigs, commitSig: CommitSig, liquidityPurchase_opt: Option[LiquidityAds.Purchase], nextRemoteCommitNonce_opt: Option[(TxId, IndividualNonce)]) extends Response sealed trait Failed extends Response { def cause: ChannelException } case class LocalFailure(cause: ChannelException) extends Failed case class RemoteFailure(cause: ChannelException) extends Failed @@ -890,15 +890,12 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon val localNonce = NonceGenerator.signingNonce(localFundingKey.publicKey, fundingParams.remoteFundingPubKey, fundingTx.txid) val remoteNonce = session.txCompleteReceived.flatMap(_.nonces_opt).map(_.commitNonce) match { case Some(n) => n - case None => return Left(MissingNonce(channelParams.channelId, fundingTx.txid)) + case None => return Left(MissingCommitNonce(channelParams.channelId, fundingTx.txid, purpose.remoteCommitIndex)) } val psig = remoteCommitTx.partialSign(localFundingKey, fundingParams.remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteNonce)) match { - case Left(e) => - println(e) - return Left(MissingNonce(channelParams.channelId, fundingTx.txid)) + case Left(_) => return Left(InvalidCommitNonce(channelParams.channelId, fundingTx.txid, purpose.remoteCommitIndex)) case Right(psig) => psig } - log.debug(s"signCommitTx: creating partial signature $psig for commit tx ${remoteCommitTx.tx.txid} with local nonce ${localNonce.publicNonce} remote nonce $remoteNonce") Right(TlvStream(CommitSigTlv.PartialSignatureWithNonceTlv(psig))) case _: SegwitV0CommitmentFormat => Right(TlvStream.empty[CommitSigTlv]) } @@ -988,12 +985,12 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon val remoteNonce = session.txCompleteReceived.flatMap(_.nonces_opt).flatMap(_.fundingNonce_opt) match { case Some(n) => n case None => - context.self ! WalletFailure(MissingFundingNonce(channelParams.channelId)) + context.self ! WalletFailure(MissingFundingNonce(channelParams.channelId, tx.txid)) return } val psig = Musig2.signTaprootInput(fundingKey, tx, inputIndex, unsignedTx.spentOutputs, Scripts.sort(Seq(fundingKey.publicKey, i.remoteFundingPubkey)), localNonce.secretNonce, Seq(localNonce.publicNonce, remoteNonce), None) match { case Left(_) => - context.self ! WalletFailure(InvalidFundingNonce(channelParams.channelId)) + context.self ! WalletFailure(InvalidFundingNonce(channelParams.channelId, tx.txid)) return case Right(psig) => psig } 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 6dc9d7e404..14645e6b54 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 @@ -203,7 +203,7 @@ case class ChannelReestablish(channelId: ByteVector32, val nextFundingTxId_opt: Option[TxId] = tlvStream.get[ChannelReestablishTlv.NextFundingTlv].map(_.txId) val myCurrentFundingLocked_opt: Option[TxId] = tlvStream.get[ChannelReestablishTlv.MyCurrentFundingLockedTlv].map(_.txId) val yourLastFundingLocked_opt: Option[TxId] = tlvStream.get[ChannelReestablishTlv.YourLastFundingLockedTlv].map(_.txId) - val nextLocalNonces: Map[TxId, IndividualNonce] = tlvStream.get[ChannelReestablishTlv.NextLocalNoncesTlv].map(_.nonces.toMap).getOrElse(Map.empty) + val nextCommitNonces: Map[TxId, IndividualNonce] = tlvStream.get[ChannelReestablishTlv.NextLocalNoncesTlv].map(_.nonces.toMap).getOrElse(Map.empty) val currentCommitNonce_opt: Option[IndividualNonce] = tlvStream.get[ChannelReestablishTlv.CurrentCommitNonceTlv].map(_.nonce) } @@ -228,7 +228,7 @@ case class OpenChannel(chainHash: BlockHash, tlvStream: TlvStream[OpenChannelTlv] = TlvStream.empty) extends ChannelMessage with HasTemporaryChannelId with HasChainHash { val upfrontShutdownScript_opt: Option[ByteVector] = tlvStream.get[ChannelTlv.UpfrontShutdownScriptTlv].map(_.script) val channelType_opt: Option[ChannelType] = tlvStream.get[ChannelTlv.ChannelTypeTlv].map(_.channelType) - val nextLocalNonce_opt: Option[IndividualNonce] = tlvStream.get[ChannelTlv.NextLocalNonceTlv].map(_.nonce) + val commitNonce_opt: Option[IndividualNonce] = tlvStream.get[ChannelTlv.NextLocalNonceTlv].map(_.nonce) } case class AcceptChannel(temporaryChannelId: ByteVector32, @@ -248,7 +248,7 @@ case class AcceptChannel(temporaryChannelId: ByteVector32, tlvStream: TlvStream[AcceptChannelTlv] = TlvStream.empty) extends ChannelMessage with HasTemporaryChannelId { val upfrontShutdownScript_opt: Option[ByteVector] = tlvStream.get[ChannelTlv.UpfrontShutdownScriptTlv].map(_.script) val channelType_opt: Option[ChannelType] = tlvStream.get[ChannelTlv.ChannelTypeTlv].map(_.channelType) - val nextLocalNonce_opt: Option[IndividualNonce] = tlvStream.get[ChannelTlv.NextLocalNonceTlv].map(_.nonce) + val commitNonce_opt: Option[IndividualNonce] = tlvStream.get[ChannelTlv.NextLocalNonceTlv].map(_.nonce) } // NB: this message is named open_channel2 in the specification. @@ -313,17 +313,45 @@ case class FundingCreated(temporaryChannelId: ByteVector32, val sigOrPartialSig: ChannelSpendSignature = tlvStream.get[ChannelTlv.PartialSignatureWithNonceTlv].map(_.partialSigWithNonce).getOrElse(IndividualSignature(signature)) } +object FundingCreated { + def apply(temporaryChannelId: ByteVector32, fundingTxId: TxId, fundingOutputIndex: Int, sig: ChannelSpendSignature): FundingCreated = { + val individualSig = sig match { + case IndividualSignature(sig) => sig + case _: PartialSignatureWithNonce => ByteVector64.Zeroes + } + val tlvs = sig match { + case _: IndividualSignature => TlvStream.empty[FundingCreatedTlv] + case psig: PartialSignatureWithNonce => TlvStream[FundingCreatedTlv](ChannelTlv.PartialSignatureWithNonceTlv(psig)) + } + FundingCreated(temporaryChannelId, fundingTxId, fundingOutputIndex, individualSig, tlvs) + } +} + case class FundingSigned(channelId: ByteVector32, signature: ByteVector64, tlvStream: TlvStream[FundingSignedTlv] = TlvStream.empty) extends ChannelMessage with HasChannelId { val sigOrPartialSig: ChannelSpendSignature = tlvStream.get[ChannelTlv.PartialSignatureWithNonceTlv].map(_.partialSigWithNonce).getOrElse(IndividualSignature(signature)) } +object FundingSigned { + def apply(channelId: ByteVector32, sig: ChannelSpendSignature): FundingSigned = { + val individualSig = sig match { + case IndividualSignature(sig) => sig + case _: PartialSignatureWithNonce => ByteVector64.Zeroes + } + val tlvs = sig match { + case _: IndividualSignature => TlvStream.empty[FundingSignedTlv] + case psig: PartialSignatureWithNonce => TlvStream[FundingSignedTlv](ChannelTlv.PartialSignatureWithNonceTlv(psig)) + } + FundingSigned(channelId, individualSig, tlvs) + } +} + case class ChannelReady(channelId: ByteVector32, nextPerCommitmentPoint: PublicKey, tlvStream: TlvStream[ChannelReadyTlv] = TlvStream.empty) extends ChannelMessage with HasChannelId { val alias_opt: Option[Alias] = tlvStream.get[ShortChannelIdTlv].map(_.alias) - val nextLocalNonce_opt: Option[IndividualNonce] = tlvStream.get[ChannelTlv.NextLocalNonceTlv].map(_.nonce) + val nextCommitNonce_opt: Option[IndividualNonce] = tlvStream.get[ChannelTlv.NextLocalNonceTlv].map(_.nonce) } object ChannelReady { @@ -529,13 +557,13 @@ case class RevokeAndAck(channelId: ByteVector32, perCommitmentSecret: PrivateKey, nextPerCommitmentPoint: PublicKey, tlvStream: TlvStream[RevokeAndAckTlv] = TlvStream.empty) extends HtlcMessage with HasChannelId { - val nextLocalNonces: Map[TxId, IndividualNonce] = tlvStream.get[RevokeAndAckTlv.NextLocalNoncesTlv].map(_.nonces.toMap).getOrElse(Map.empty) + val nextCommitNonces: Map[TxId, IndividualNonce] = tlvStream.get[RevokeAndAckTlv.NextLocalNoncesTlv].map(_.nonces.toMap).getOrElse(Map.empty) } object RevokeAndAck { - def apply(channelId: ByteVector32, perCommitmentSecret: PrivateKey, nextPerCommitmentPoint: PublicKey, nextLocalNonces: Seq[(TxId, IndividualNonce)]): RevokeAndAck = { + def apply(channelId: ByteVector32, perCommitmentSecret: PrivateKey, nextPerCommitmentPoint: PublicKey, nextCommitNonces: Seq[(TxId, IndividualNonce)]): RevokeAndAck = { val tlvs = Set( - if (nextLocalNonces.nonEmpty) Some(RevokeAndAckTlv.NextLocalNoncesTlv(nextLocalNonces)) else None + if (nextCommitNonces.nonEmpty) Some(RevokeAndAckTlv.NextLocalNoncesTlv(nextCommitNonces)) else None ).flatten[RevokeAndAckTlv] RevokeAndAck(channelId, perCommitmentSecret, nextPerCommitmentPoint, TlvStream(tlvs)) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptChannelStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptChannelStateSpec.scala index 09b700ae3c..4436b8e652 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptChannelStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptChannelStateSpec.scala @@ -112,7 +112,7 @@ class WaitForAcceptChannelStateSpec extends TestKitBaseClass with FixtureAnyFunS import f._ val accept = bob2alice.expectMsgType[AcceptChannel] assert(accept.channelType_opt.contains(ChannelTypes.SimpleTaprootChannelsStagingLegacy())) - assert(accept.nextLocalNonce_opt.isDefined) + assert(accept.commitNonce_opt.isDefined) bob2alice.forward(alice) awaitCond(alice.stateName == WAIT_FOR_FUNDING_INTERNAL) assert(alice.stateData.asInstanceOf[DATA_WAIT_FOR_FUNDING_INTERNAL].commitmentFormat == LegacySimpleTaprootChannelCommitmentFormat) @@ -123,9 +123,9 @@ class WaitForAcceptChannelStateSpec extends TestKitBaseClass with FixtureAnyFunS import f._ val accept = bob2alice.expectMsgType[AcceptChannel] assert(accept.channelType_opt.contains(ChannelTypes.SimpleTaprootChannelsStagingLegacy())) - assert(accept.nextLocalNonce_opt.isDefined) + assert(accept.commitNonce_opt.isDefined) bob2alice.forward(alice, accept.copy(tlvStream = accept.tlvStream.copy(records = accept.tlvStream.records.filterNot(_.isInstanceOf[ChannelTlv.NextLocalNonceTlv])))) - alice2bob.expectMsg(Error(accept.temporaryChannelId, MissingNonce(accept.temporaryChannelId, TxId(ByteVector32.Zeroes)).getMessage)) + alice2bob.expectMsg(Error(accept.temporaryChannelId, MissingCommitNonce(accept.temporaryChannelId, TxId(ByteVector32.Zeroes), 0).getMessage)) listener.expectMsgType[ChannelAborted] awaitCond(alice.stateName == CLOSED) aliceOpenReplyTo.expectMsgType[OpenChannelResponse.Rejected] diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenChannelStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenChannelStateSpec.scala index 2794d75c92..3c98e5fc45 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenChannelStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenChannelStateSpec.scala @@ -19,7 +19,7 @@ package fr.acinq.eclair.channel.states.a import akka.testkit.{TestFSMRef, TestProbe} import com.softwaremill.quicklens.ModifyPimp import fr.acinq.bitcoin.scalacompat.{Block, Btc, ByteVector32, SatoshiLong, TxId} -import fr.acinq.eclair.TestConstants.{Alice, Bob} +import fr.acinq.eclair.TestConstants.Bob import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fsm.Channel @@ -114,17 +114,17 @@ class WaitForOpenChannelStateSpec extends TestKitBaseClass with FixtureAnyFunSui alice2bob.forward(bob) awaitCond(bob.stateName == WAIT_FOR_FUNDING_CREATED) assert(bob.stateData.asInstanceOf[DATA_WAIT_FOR_FUNDING_CREATED].commitmentFormat == LegacySimpleTaprootChannelCommitmentFormat) - assert(open.nextLocalNonce_opt.isDefined) + assert(open.commitNonce_opt.isDefined) } test("recv OpenChannel (simple taproot channels, missing nonce)", Tag(ChannelStateTestsTags.OptionSimpleTaprootStagingLegacy)) { f => import f._ val open = alice2bob.expectMsgType[OpenChannel] assert(open.channelType_opt.contains(ChannelTypes.SimpleTaprootChannelsStagingLegacy())) - assert(open.nextLocalNonce_opt.isDefined) + assert(open.commitNonce_opt.isDefined) alice2bob.forward(bob, open.copy(tlvStream = open.tlvStream.copy(records = open.tlvStream.records.filterNot(_.isInstanceOf[ChannelTlv.NextLocalNonceTlv])))) val error = bob2alice.expectMsgType[Error] - assert(error == Error(open.temporaryChannelId, MissingNonce(open.temporaryChannelId, TxId(ByteVector32.Zeroes)).getMessage)) + assert(error == Error(open.temporaryChannelId, MissingCommitNonce(open.temporaryChannelId, TxId(ByteVector32.Zeroes), 0).getMessage)) listener.expectMsgType[ChannelAborted] awaitCond(bob.stateName == CLOSED) } From 6fb5af29430cc41c18c1feed1b99d18f35e55106 Mon Sep 17 00:00:00 2001 From: t-bast Date: Mon, 4 Aug 2025 15:02:33 +0200 Subject: [PATCH 11/18] Simplify error handling The previous code was calling `handleLocalError` in `spendLocalCurrent` which created an infinite loop, since `handleLocalError` was itself calling `spendLocalCurrent` to publish the commit tx. This was clearly untested, otherwise this issue would have been noticed earlier. More tests will be added in future commits to address this issue. We can actually greatly simplify error handling during force-close by making `fullySignedCommitTx` ignore errors, which is safe since we only use this *after* receiving the remote `commit_sig`, which we validate (we force-close with the previous commitment if the remote nonce or partial sig is invalid). We improve pattern matching to fully match on the commitment format, which is important to ensure that when adding v3 taproot channels we automatically get a compiler error to give us the opportunity to set nonces correctly, otherwise we will silently miss some code paths. We also fix the `Shutdown` state, where the remote commit nonces were not provided to the `sendCommit` method, which means we couldn't settle HTLCs for taproot channels after exchanging `shutdown`. This needs unit tests as well, which will be added in future commits. --- .../fr/acinq/eclair/channel/Commitments.scala | 109 +++++++----------- .../fr/acinq/eclair/channel/Helpers.scala | 9 +- .../fr/acinq/eclair/channel/fsm/Channel.scala | 4 +- .../channel/fsm/CommonFundingHandlers.scala | 2 +- .../eclair/channel/fsm/ErrorHandlers.scala | 6 +- .../wire/protocol/LightningMessageTypes.scala | 13 ++- .../eclair/balance/CheckBalanceSpec.scala | 7 +- .../eclair/channel/CommitmentsSpec.scala | 28 ++--- .../channel/InteractiveTxBuilderSpec.scala | 2 +- .../fr/acinq/eclair/channel/RestoreSpec.scala | 4 +- .../publish/ReplaceableTxPublisherSpec.scala | 27 ++--- .../ChannelStateTestsHelperMethods.scala | 2 +- .../states/e/NormalSplicesStateSpec.scala | 11 +- .../integration/ChannelIntegrationSpec.scala | 2 +- .../acinq/eclair/io/PeerConnectionSpec.scala | 22 ++-- .../protocol/LightningMessageCodecsSpec.scala | 2 +- 16 files changed, 115 insertions(+), 135 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala index 1f202fbbb6..4d802e2255 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala @@ -3,7 +3,7 @@ package fr.acinq.eclair.channel import akka.event.LoggingAdapter import fr.acinq.bitcoin.crypto.musig2.IndividualNonce import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} -import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, Crypto, Musig2, OutPoint, Satoshi, SatoshiLong, Script, Transaction, TxId} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, Crypto, OutPoint, Satoshi, SatoshiLong, Transaction, TxId} import fr.acinq.eclair.blockchain.fee.{FeeratePerByte, FeeratePerKw, FeeratesPerKw, OnChainFeeConf} import fr.acinq.eclair.channel.ChannelSpendSignature.{IndividualSignature, PartialSignatureWithNonce} import fr.acinq.eclair.channel.Helpers.Closing @@ -171,13 +171,14 @@ object LocalCommit { fundingKey: PrivateKey, remoteFundingPubKey: PublicKey, commitInput: InputInfo, commit: CommitSig, localCommitIndex: Long, spec: CommitmentSpec, commitmentFormat: CommitmentFormat): Either[ChannelException, LocalCommit] = { val (localCommitTx, htlcTxs) = Commitment.makeLocalTxs(channelParams, commitParams, commitKeys, localCommitIndex, fundingKey, remoteFundingPubKey, commitInput, commitmentFormat, spec) - val remoteCommitSigOk = commit.sigOrPartialSig match { - case IndividualSignature(sig) if !commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => - localCommitTx.checkRemoteSig(fundingKey.publicKey, remoteFundingPubKey, IndividualSignature(sig)) - case psig: PartialSignatureWithNonce if commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => - val localNonce = NonceGenerator.verificationNonce(fundingTxId, fundingKey, remoteFundingPubKey, localCommitIndex) - localCommitTx.checkRemotePartialSignature(fundingKey.publicKey, remoteFundingPubKey, psig, localNonce.publicNonce) - case _ => false + val remoteCommitSigOk = commitmentFormat match { + case _: SegwitV0CommitmentFormat => localCommitTx.checkRemoteSig(fundingKey.publicKey, remoteFundingPubKey, commit.signature) + case _: SimpleTaprootChannelCommitmentFormat => commit.sigOrPartialSig match { + case _: IndividualSignature => false + case remoteSig: PartialSignatureWithNonce => + val localNonce = NonceGenerator.verificationNonce(fundingTxId, fundingKey, remoteFundingPubKey, localCommitIndex) + localCommitTx.checkRemotePartialSignature(fundingKey.publicKey, remoteFundingPubKey, remoteSig, localNonce.publicNonce) + } } if (!remoteCommitSigOk) { return Left(InvalidCommitmentSignature(channelParams.channelId, fundingTxId, localCommitIndex, localCommitTx.tx)) @@ -209,13 +210,14 @@ case class RemoteCommit(index: Long, spec: CommitmentSpec, txId: TxId, remotePer case _: SegwitV0CommitmentFormat => val sig = remoteCommitTx.sign(fundingKey, remoteFundingPubKey) Right(CommitSig(channelParams.channelId, sig, htlcSigs.toList)) - case _: SimpleTaprootChannelCommitmentFormat if remoteNonce_opt.isEmpty => - Left(MissingCommitNonce(channelParams.channelId, commitInput.outPoint.txid, index)) case _: SimpleTaprootChannelCommitmentFormat => val localNonce = NonceGenerator.signingNonce(fundingKey.publicKey, remoteFundingPubKey, commitInput.outPoint.txid) - remoteCommitTx.partialSign(fundingKey, remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteNonce_opt.get)) match { - case Left(_) => Left(InvalidCommitNonce(channelParams.channelId, commitInput.outPoint.txid, index)) - case Right(psig) => Right(CommitSig(channelParams.channelId, psig, htlcSigs.toList)) + remoteNonce_opt match { + case Some(remoteNonce) => remoteCommitTx.partialSign(fundingKey, remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteNonce)) match { + case Left(_) => Left(InvalidCommitNonce(channelParams.channelId, commitInput.outPoint.txid, index)) + case Right(psig) => Right(CommitSig(channelParams.channelId, psig, htlcSigs.toList, batchSize = 1)) + } + case None => Left(MissingCommitNonce(channelParams.channelId, commitInput.outPoint.txid, index)) } } } @@ -660,34 +662,23 @@ case class Commitment(fundingTxIndex: Long, val fundingKey = localFundingKey(channelKeys) val (remoteCommitTx, htlcTxs) = Commitment.makeRemoteTxs(params, remoteCommitParams, commitKeys, remoteCommit.index + 1, fundingKey, remoteFundingPubKey, commitInput(fundingKey), commitmentFormat, spec) val htlcSigs = htlcTxs.sortBy(_.input.outPoint.index).map(_.localSig(commitKeys)) - // NB: IN/OUT htlcs are inverted because this is the remote commit log.info(s"built remote commit number=${remoteCommit.index + 1} toLocalMsat=${spec.toLocal.toLong} toRemoteMsat=${spec.toRemote.toLong} htlc_in={} htlc_out={} feeratePerKw=${spec.commitTxFeerate} txid=${remoteCommitTx.tx.txid} fundingTxId=$fundingTxId", spec.htlcs.collect(DirectedHtlc.outgoing).map(_.id).mkString(","), spec.htlcs.collect(DirectedHtlc.incoming).map(_.id).mkString(",")) Metrics.recordHtlcsInFlight(spec, remoteCommit.spec) - val partialSig: Option[CommitSigTlv] = commitmentFormat match { + val sig = commitmentFormat match { + case _: SegwitV0CommitmentFormat => remoteCommitTx.sign(fundingKey, remoteFundingPubKey) case _: SimpleTaprootChannelCommitmentFormat => val localNonce = NonceGenerator.signingNonce(fundingKey.publicKey, remoteFundingPubKey, fundingTxId) - if (nextRemoteNonce_opt.isEmpty) - return Left(MissingCommitNonce(params.channelId, fundingTxId, remoteCommit.index + 1)) - val Some(remoteNonce) = nextRemoteNonce_opt - val psig = remoteCommitTx.partialSign(fundingKey, remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteNonce)) match { - case Left(_) => return Left(InvalidCommitNonce(params.channelId, fundingTxId, remoteCommit.index + 1)) - case Right(psig) => psig + nextRemoteNonce_opt match { + case Some(remoteNonce) => + remoteCommitTx.partialSign(fundingKey, remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteNonce)) match { + case Left(_) => return Left(InvalidCommitNonce(params.channelId, fundingTxId, remoteCommit.index + 1)) + case Right(psig) => psig + } + case None => return Left(MissingCommitNonce(params.channelId, fundingTxId, remoteCommit.index + 1)) } - log.debug(s"sendCommit: creating partial sig $psig for remote commit tx ${remoteCommitTx.tx.txid} with fundingTxIndex = $fundingTxIndex remoteCommit.index (should add +1) = ${remoteCommit.index} remote nonce $remoteNonce and remoteNextPerCommitmentPoint = $remoteNextPerCommitmentPoint") - Some(CommitSigTlv.PartialSignatureWithNonceTlv(psig)) - case _: AnchorOutputsCommitmentFormat | DefaultCommitmentFormat => - None - } - val sig = commitmentFormat match { - case _: SimpleTaprootChannelCommitmentFormat => IndividualSignature(ByteVector64.Zeroes) - case _: SegwitV0CommitmentFormat => remoteCommitTx.sign(fundingKey, remoteFundingPubKey) } - val tlvs = Set( - if (batchSize > 1) Some(CommitSigTlv.BatchTlv(batchSize)) else None, - partialSig - ).flatten[CommitSigTlv] - val commitSig = CommitSig(params.channelId, sig, htlcSigs.toList, TlvStream(tlvs)) + val commitSig = CommitSig(params.channelId, sig, htlcSigs.toList, batchSize) val nextRemoteCommit = NextRemoteCommit(commitSig, RemoteCommit(remoteCommit.index + 1, spec, remoteCommitTx.tx.txid, remoteNextPerCommitmentPoint)) Right((copy(nextRemoteCommit_opt = Some(nextRemoteCommit)), commitSig)) } @@ -711,37 +702,26 @@ case class Commitment(fundingTxIndex: Long, } /** Return a fully signed commit tx, that can be published as-is. */ - def fullySignedLocalCommitTx(params: ChannelParams, channelKeys: ChannelKeys): Either[ChannelException, Transaction] = { + def fullySignedLocalCommitTx(params: ChannelParams, channelKeys: ChannelKeys): Transaction = { val fundingKey = localFundingKey(channelKeys) val commitKeys = localKeys(params, channelKeys) val (unsignedCommitTx, _) = Commitment.makeLocalTxs(params, localCommitParams, commitKeys, localCommit.index, fundingKey, remoteFundingPubKey, commitInput(fundingKey), commitmentFormat, localCommit.spec) localCommit.remoteSig match { - case remoteSig: IndividualSignature if !commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => + case remoteSig: IndividualSignature => val localSig = unsignedCommitTx.sign(fundingKey, remoteFundingPubKey) - Right(unsignedCommitTx.aggregateSigs(fundingKey.publicKey, remoteFundingPubKey, localSig, remoteSig)) - case PartialSignatureWithNonce(remotePsig, remoteNonce) if commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => + unsignedCommitTx.aggregateSigs(fundingKey.publicKey, remoteFundingPubKey, localSig, remoteSig) + case remoteSig: PartialSignatureWithNonce => val localNonce = if (fundingTxIndex == 0 && localCommit.index == 0 && !params.channelFeatures.hasFeature(Features.DualFunding)) { // With channel establishment v1, we exchange the first nonce before the funding tx and remote funding key are known. NonceGenerator.verificationNonce(NonceGenerator.dummyFundingTxId, fundingKey, NonceGenerator.dummyRemoteFundingPubKey, localCommit.index) } else { NonceGenerator.verificationNonce(fundingTxId, fundingKey, remoteFundingPubKey, localCommit.index) } - (for { - partialSig <- unsignedCommitTx.partialSign(fundingKey, remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteNonce)) - aggSig <- Musig2.aggregateTaprootSignatures( - Seq(partialSig.partialSig, remotePsig), - unsignedCommitTx.tx, - inputIndex = 0, // commit txs always have a single input - Seq(unsignedCommitTx.input.txOut), - Scripts.sort(Seq(fundingKey.publicKey, remoteFundingPubKey)), - Seq(localNonce.publicNonce, remoteNonce), - None) - signedCommitTx = unsignedCommitTx.copy(tx = unsignedCommitTx.tx.updateWitness(0, Script.witnessKeyPathPay2tr(aggSig))) - } yield signedCommitTx.tx) match { - case Left(_) => Left(InvalidCommitmentSignature(params.channelId, fundingTxId, localCommit.index, unsignedCommitTx.tx)) - case Right(tx) => Right(tx) - } - case _ => Left(InvalidCommitmentSignature(params.channelId, fundingTxId, localCommit.index, unsignedCommitTx.tx)) + // We have already validated the remote nonce and partial signature when we received it, so we're guaranteed + // that the following code cannot produce an error. + val Right(localSig) = unsignedCommitTx.partialSign(fundingKey, remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteSig.nonce)) + val Right(signedTx) = unsignedCommitTx.aggregateSigs(fundingKey.publicKey, remoteFundingPubKey, localSig, remoteSig) + signedTx } } @@ -829,7 +809,7 @@ case class FullCommitment(channelParams: ChannelParams, changes: CommitmentChang def remoteChannelReserve: Satoshi = commitment.remoteChannelReserve(channelParams) - def fullySignedLocalCommitTx(channelKeys: ChannelKeys): Either[ChannelException, Transaction] = commitment.fullySignedLocalCommitTx(channelParams, channelKeys) + def fullySignedLocalCommitTx(channelKeys: ChannelKeys): Transaction = commitment.fullySignedLocalCommitTx(channelParams, channelKeys) def htlcTxs(channelKeys: ChannelKeys): Seq[(UnsignedHtlcTx, ByteVector64)] = commitment.htlcTxs(channelParams, channelKeys) @@ -1101,19 +1081,13 @@ case class Commitments(channelParams: ChannelParams, } } - def sendCommit(channelKeys: ChannelKeys, nextRemoteNonces: Map[TxId, IndividualNonce] = Map.empty)(implicit log: LoggingAdapter): Either[ChannelException, (Commitments, CommitSigs)] = { + def sendCommit(channelKeys: ChannelKeys, nextRemoteCommitNonces: Map[TxId, IndividualNonce])(implicit log: LoggingAdapter): Either[ChannelException, (Commitments, CommitSigs)] = { remoteNextCommitInfo match { case Right(_) if !changes.localHasChanges => Left(CannotSignWithoutChanges(channelId)) case Right(remoteNextPerCommitmentPoint) => - - def remoteNonce(c: Commitment) = c.commitmentFormat match { - case _: SimpleTaprootChannelCommitmentFormat => nextRemoteNonces.get(c.fundingTxId) - case _: AnchorOutputsCommitmentFormat | DefaultCommitmentFormat => None - } - val (active1, sigs) = active.map(c => { val commitKeys = RemoteCommitmentKeys(channelParams, channelKeys, remoteNextPerCommitmentPoint, c.commitmentFormat) - c.sendCommit(channelParams, channelKeys, commitKeys, changes, remoteNextPerCommitmentPoint, active.size, remoteNonce(c)) match { + c.sendCommit(channelParams, channelKeys, commitKeys, changes, remoteNextPerCommitmentPoint, active.size, nextRemoteCommitNonces.get(c.fundingTxId)) match { case Left(e) => return Left(e) case Right((c, cs)) => (c, cs) } @@ -1152,11 +1126,12 @@ case class Commitments(channelParams: ChannelParams, // we will send our revocation preimage + our next revocation hash val localPerCommitmentSecret = channelKeys.commitmentSecret(localCommitIndex) val localNextPerCommitmentPoint = channelKeys.commitmentPoint(localCommitIndex + 2) - val localCommitNonces = active.collect { - case c: Commitment if c.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => + val localCommitNonces = active.flatMap(c => c.commitmentFormat match { + case _: SegwitV0CommitmentFormat => None + case _: SimpleTaprootChannelCommitmentFormat => val localNonce = NonceGenerator.verificationNonce(c.fundingTxId, c.localFundingKey(channelKeys), c.remoteFundingPubKey, localCommitIndex + 2) - c.fundingTxId -> localNonce.publicNonce - } + Some(c.fundingTxId -> localNonce.publicNonce) + }) val revocation = RevokeAndAck( channelId = channelId, perCommitmentSecret = localPerCommitmentSecret, diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala index 07765555b2..b277437a92 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala @@ -546,12 +546,13 @@ object Helpers { // they just sent a new commit_sig, we have received it but they didn't receive our revocation val localPerCommitmentSecret = channelKeys.commitmentSecret(commitments.localCommitIndex - 1) val localNextPerCommitmentPoint = channelKeys.commitmentPoint(commitments.localCommitIndex + 1) - val localCommitNonces = commitments.active.collect { - case c: Commitment if c.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => + val localCommitNonces = commitments.active.flatMap(c => c.commitmentFormat match { + case _: SegwitV0CommitmentFormat => None + case _: SimpleTaprootChannelCommitmentFormat => val fundingKey = channelKeys.fundingKey(c.fundingTxIndex) val n = NonceGenerator.verificationNonce(c.fundingTxId, fundingKey, c.remoteFundingPubKey, commitments.localCommitIndex + 1).publicNonce - c.fundingTxId -> n - } + Some(c.fundingTxId -> n) + }) val revocation = RevokeAndAck( channelId = commitments.channelId, perCommitmentSecret = localPerCommitmentSecret, diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala index e4dfb68b9b..990bc18ceb 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala @@ -1643,7 +1643,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall log.debug("ignoring CMD_SIGN (nothing to sign)") stay() case Right(_) => - d.commitments.sendCommit(channelKeys) match { + d.commitments.sendCommit(channelKeys, remoteNextCommitNonces) match { case Right((commitments1, commit)) => log.debug("sending a new sig, spec:\n{}", commitments1.latest.specs2String) val nextRemoteCommit = commitments1.latest.nextRemoteCommit_opt.get.commit @@ -2391,6 +2391,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall val myFirstPerCommitmentPoint = channelKeys.commitmentPoint(0) val nextFundingTlv: Set[ChannelReestablishTlv] = Set(ChannelReestablishTlv.NextFundingTlv(d.signingSession.fundingTxId)) val nonceTlvs = d.signingSession.fundingParams.commitmentFormat match { + case _: SegwitV0CommitmentFormat => Set.empty case _: SimpleTaprootChannelCommitmentFormat => val localFundingKey = channelKeys.fundingKey(0) val remoteFundingPubKey = d.signingSession.fundingParams.remoteFundingPubKey @@ -2400,7 +2401,6 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall ChannelReestablishTlv.NextLocalNoncesTlv(List(d.signingSession.fundingTxId -> nextCommitNonce.publicNonce)), ChannelReestablishTlv.CurrentCommitNonceTlv(currentCommitNonce.publicNonce), ) - case _: AnchorOutputsCommitmentFormat | DefaultCommitmentFormat => Set.empty } val channelReestablish = ChannelReestablish( channelId = d.channelId, 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 928fff3490..eea86d68b4 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 @@ -75,7 +75,7 @@ trait CommonFundingHandlers extends CommonHandlers { case _: SingleFundedUnconfirmedFundingTx => // in the single-funding case, as fundee, it is the first time we see the full funding tx, we must verify that it is // valid (it pays the correct amount to the correct script). We also check as funder even if it's not really useful - d.commitments.latest.fullySignedLocalCommitTx(channelKeys).toTry.flatMap(signedTx => Try(Transaction.correctlySpends(signedTx, Seq(w.tx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS))) match { + Try(Transaction.correctlySpends(d.commitments.latest.fullySignedLocalCommitTx(channelKeys), Seq(w.tx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)) match { case Success(_) => () case Failure(t) => log.error(t, s"rejecting channel with invalid funding tx: ${w.tx.bin}") diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ErrorHandlers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ErrorHandlers.scala index cfe1eb062e..90e2220dfa 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ErrorHandlers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ErrorHandlers.scala @@ -210,11 +210,7 @@ trait ErrorHandlers extends CommonHandlers { val commitment = d.commitments.latest log.error(s"force-closing with fundingIndex=${commitment.fundingTxIndex}") context.system.eventStream.publish(NotifyNodeOperator(NotificationsLogger.Error, s"force-closing channel ${d.channelId} with fundingIndex=${commitment.fundingTxIndex}")) - val commitTx = commitment.fullySignedLocalCommitTx(channelKeys) match { - case Right(signedTx) => signedTx - case Left(channelException: ChannelException) => - return handleLocalError(channelException, d, None) - } + val commitTx = commitment.fullySignedLocalCommitTx(channelKeys) val (localCommitPublished, closingTxs) = Closing.LocalClose.claimCommitTxOutputs(channelKeys, commitment, commitTx, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf, finalScriptPubKey) val nextData = d match { case closing: DATA_CLOSING => closing.copy(localCommitPublished = Some(localCommitPublished)) 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 14645e6b54..74129f5312 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 @@ -541,9 +541,16 @@ case class CommitSig(channelId: ByteVector32, } object CommitSig { - def apply(channelId: ByteVector32, signature: PartialSignatureWithNonce, htlcSignatures: List[ByteVector64]): CommitSig = { - val emptySig = IndividualSignature(ByteVector64.Zeroes) - CommitSig(channelId, emptySig, htlcSignatures, TlvStream(CommitSigTlv.PartialSignatureWithNonceTlv(signature))) + def apply(channelId: ByteVector32, signature: ChannelSpendSignature, htlcSignatures: List[ByteVector64], batchSize: Int): CommitSig = { + val (individualSig, partialSig_opt) = signature match { + case sig: IndividualSignature => (sig, None) + case psig: PartialSignatureWithNonce => (IndividualSignature(ByteVector64.Zeroes), Some(psig)) + } + val tlvs = Set( + if (batchSize > 1) Some(CommitSigTlv.BatchTlv(batchSize)) else None, + partialSig_opt.map(CommitSigTlv.PartialSignatureWithNonceTlv(_)) + ).flatten[CommitSigTlv] + CommitSig(channelId, individualSig, htlcSignatures, TlvStream(tlvs)) } } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/balance/CheckBalanceSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/balance/CheckBalanceSpec.scala index 4c867e8026..27a919600b 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/balance/CheckBalanceSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/balance/CheckBalanceSpec.scala @@ -5,11 +5,12 @@ import fr.acinq.eclair.balance.CheckBalance.{MainAndHtlcBalance, OffChainBalance import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{apply => _, _} import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.publish.TxPublisher.PublishReplaceableTx +import fr.acinq.eclair.channel.states.ChannelStateTestsBase.PimpTestFSM import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags} import fr.acinq.eclair.db.jdbc.JdbcUtils.ExtendedResultSet._ import fr.acinq.eclair.db.pg.PgUtils.using import fr.acinq.eclair.testutils.PimpTestProbe.convert -import fr.acinq.eclair.transactions.Transactions.{ClaimHtlcSuccessTx, ClaimHtlcTimeoutTx, ClaimLocalAnchorTx, ClaimRemoteAnchorTx} +import fr.acinq.eclair.transactions.Transactions.{ClaimHtlcSuccessTx, ClaimHtlcTimeoutTx, ClaimRemoteAnchorTx} import fr.acinq.eclair.wire.internal.channel.ChannelCodecs.channelDataCodec import fr.acinq.eclair.wire.protocol.{CommitSig, RevokeAndAck} import fr.acinq.eclair.{BlockHeight, MilliSatoshiLong, TestConstants, TestKitBaseClass, ToMilliSatoshiConversion} @@ -87,7 +88,7 @@ class CheckBalanceSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with fulfillHtlc(htlcb.id, rb, alice, bob, alice2bob, bob2alice) // Bob publishes his current commit tx. - val bobCommitTx = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.fullySignedLocalCommitTx(bob.underlyingActor.channelKeys).toTry.get + val bobCommitTx = bob.signCommitTx() assert(bobCommitTx.txOut.size == 8) // two anchor outputs, two main outputs and 4 pending htlcs alice ! WatchFundingSpentTriggered(bobCommitTx) // In response to that, alice publishes her claim txs. @@ -136,7 +137,7 @@ class CheckBalanceSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with bob2alice.expectMsgType[RevokeAndAck] // Bob publishes his next commit tx. - val bobCommitTx = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.fullySignedLocalCommitTx(bob.underlyingActor.channelKeys).toTry.get + val bobCommitTx = bob.signCommitTx() assert(bobCommitTx.txOut.size == 7) // two anchor outputs, two main outputs and 3 pending htlcs alice ! WatchFundingSpentTriggered(bobCommitTx) // In response to that, alice publishes her claim txs 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 0f56624625..28611e0c5b 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 @@ -91,7 +91,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(bc1.availableBalanceForSend == b) assert(bc1.availableBalanceForReceive == a - p - htlcOutputFee) - val Right((ac2, commit1)) = ac1.sendCommit(alice.underlyingActor.channelKeys) + val Right((ac2, commit1)) = ac1.sendCommit(alice.underlyingActor.channelKeys, Map.empty) assert(ac2.availableBalanceForSend == a - p - htlcOutputFee) assert(ac2.availableBalanceForReceive == b) @@ -103,7 +103,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(ac3.availableBalanceForSend == a - p - htlcOutputFee) assert(ac3.availableBalanceForReceive == b) - val Right((bc3, commit2)) = bc2.sendCommit(bob.underlyingActor.channelKeys) + val Right((bc3, commit2)) = bc2.sendCommit(bob.underlyingActor.channelKeys, Map.empty) assert(bc3.availableBalanceForSend == b) assert(bc3.availableBalanceForReceive == a - p - htlcOutputFee) @@ -124,7 +124,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(ac5.availableBalanceForSend == a - p - htlcOutputFee) assert(ac5.availableBalanceForReceive == b + p) - val Right((bc6, commit3)) = bc5.sendCommit(bob.underlyingActor.channelKeys) + val Right((bc6, commit3)) = bc5.sendCommit(bob.underlyingActor.channelKeys, Map.empty) assert(bc6.availableBalanceForSend == b + p) assert(bc6.availableBalanceForReceive == a - p - htlcOutputFee) @@ -136,7 +136,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(bc7.availableBalanceForSend == b + p) assert(bc7.availableBalanceForReceive == a - p) - val Right((ac7, commit4)) = ac6.sendCommit(alice.underlyingActor.channelKeys) + val Right((ac7, commit4)) = ac6.sendCommit(alice.underlyingActor.channelKeys, Map.empty) assert(ac7.availableBalanceForSend == a - p) assert(ac7.availableBalanceForReceive == b + p) @@ -176,7 +176,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(bc1.availableBalanceForSend == b) assert(bc1.availableBalanceForReceive == a - p - htlcOutputFee) - val Right((ac2, commit1)) = ac1.sendCommit(alice.underlyingActor.channelKeys) + val Right((ac2, commit1)) = ac1.sendCommit(alice.underlyingActor.channelKeys, Map.empty) assert(ac2.availableBalanceForSend == a - p - htlcOutputFee) assert(ac2.availableBalanceForReceive == b) @@ -188,7 +188,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(ac3.availableBalanceForSend == a - p - htlcOutputFee) assert(ac3.availableBalanceForReceive == b) - val Right((bc3, commit2)) = bc2.sendCommit(bob.underlyingActor.channelKeys) + val Right((bc3, commit2)) = bc2.sendCommit(bob.underlyingActor.channelKeys, Map.empty) assert(bc3.availableBalanceForSend == b) assert(bc3.availableBalanceForReceive == a - p - htlcOutputFee) @@ -209,7 +209,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(ac5.availableBalanceForSend == a - p - htlcOutputFee) assert(ac5.availableBalanceForReceive == b) - val Right((bc6, commit3)) = bc5.sendCommit(bob.underlyingActor.channelKeys) + val Right((bc6, commit3)) = bc5.sendCommit(bob.underlyingActor.channelKeys, Map.empty) assert(bc6.availableBalanceForSend == b) assert(bc6.availableBalanceForReceive == a - p - htlcOutputFee) @@ -221,7 +221,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(bc7.availableBalanceForSend == b) assert(bc7.availableBalanceForReceive == a) - val Right((ac7, commit4)) = ac6.sendCommit(alice.underlyingActor.channelKeys) + val Right((ac7, commit4)) = ac6.sendCommit(alice.underlyingActor.channelKeys, Map.empty) assert(ac7.availableBalanceForSend == a) assert(ac7.availableBalanceForReceive == b) @@ -282,7 +282,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(ac3.availableBalanceForSend == a - p1 - htlcOutputFee - p2 - htlcOutputFee) assert(ac3.availableBalanceForReceive == b - p3) - val Right((ac4, commit1)) = ac3.sendCommit(alice.underlyingActor.channelKeys) + val Right((ac4, commit1)) = ac3.sendCommit(alice.underlyingActor.channelKeys, Map.empty) assert(ac4.availableBalanceForSend == a - p1 - htlcOutputFee - p2 - htlcOutputFee) assert(ac4.availableBalanceForReceive == b - p3) @@ -294,7 +294,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(ac5.availableBalanceForSend == a - p1 - htlcOutputFee - p2 - htlcOutputFee) assert(ac5.availableBalanceForReceive == b - p3) - val Right((bc5, commit2)) = bc4.sendCommit(bob.underlyingActor.channelKeys) + val Right((bc5, commit2)) = bc4.sendCommit(bob.underlyingActor.channelKeys, Map.empty) assert(bc5.availableBalanceForSend == b - p3) assert(bc5.availableBalanceForReceive == a - p1 - htlcOutputFee - p2 - htlcOutputFee) @@ -306,7 +306,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(bc6.availableBalanceForSend == b - p3) assert(bc6.availableBalanceForReceive == a - p1 - htlcOutputFee - p2 - htlcOutputFee - htlcOutputFee) - val Right((ac7, commit3)) = ac6.sendCommit(alice.underlyingActor.channelKeys) + val Right((ac7, commit3)) = ac6.sendCommit(alice.underlyingActor.channelKeys, Map.empty) assert(ac7.availableBalanceForSend == a - p1 - htlcOutputFee - p2 - htlcOutputFee - htlcOutputFee) assert(ac7.availableBalanceForReceive == b - p3) @@ -345,7 +345,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(bc10.availableBalanceForSend == b + p1 - p3) assert(bc10.availableBalanceForReceive == a - p1 - htlcOutputFee - p2 - htlcOutputFee + p3) // the fee for p3 disappears - val Right((ac12, commit4)) = ac11.sendCommit(alice.underlyingActor.channelKeys) + val Right((ac12, commit4)) = ac11.sendCommit(alice.underlyingActor.channelKeys, Map.empty) assert(ac12.availableBalanceForSend == a - p1 - htlcOutputFee - p2 - htlcOutputFee + p3) assert(ac12.availableBalanceForReceive == b + p1 - p3) @@ -357,7 +357,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(ac13.availableBalanceForSend == a - p1 - htlcOutputFee - p2 - htlcOutputFee + p3) assert(ac13.availableBalanceForReceive == b + p1 - p3) - val Right((bc12, commit5)) = bc11.sendCommit(bob.underlyingActor.channelKeys) + val Right((bc12, commit5)) = bc11.sendCommit(bob.underlyingActor.channelKeys, Map.empty) assert(bc12.availableBalanceForSend == b + p1 - p3) assert(bc12.availableBalanceForReceive == a - p1 - htlcOutputFee - p2 - htlcOutputFee + p3) @@ -369,7 +369,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(bc13.availableBalanceForSend == b + p1 - p3) assert(bc13.availableBalanceForReceive == a - p1 + p3) - val Right((ac15, commit6)) = ac14.sendCommit(alice.underlyingActor.channelKeys) + val Right((ac15, commit6)) = ac14.sendCommit(alice.underlyingActor.channelKeys, Map.empty) assert(ac15.availableBalanceForSend == a - p1 + p3) assert(ac15.availableBalanceForReceive == b + p1 - p3) 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 5bbd01413a..a0a9f8e0bf 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 @@ -2788,7 +2788,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit case _: SimpleTaprootChannelCommitmentFormat => val priv = randomKey() val (_, nonce) = Musig2.generateNonce(randomBytes32(), Left(priv), Seq(priv.publicKey), None, None) - CommitSig(params.channelId, PartialSignatureWithNonce(ByteVector32.Zeroes, nonce), Nil) + CommitSig(params.channelId, PartialSignatureWithNonce(ByteVector32.Zeroes, nonce), Nil, batchSize = 1) case _ => CommitSig(params.channelId, IndividualSignature(ByteVector64.Zeroes), Nil) } val Left(error) = successA1.signingSession.receiveCommitSig(params.channelParamsA, params.channelKeysA, invalidCommitSig, params.nodeParamsA.currentBlockHeight)(akka.event.NoLogging) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/RestoreSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/RestoreSpec.scala index 8782cbf201..5c1f7cc595 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/RestoreSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/RestoreSpec.scala @@ -9,7 +9,7 @@ import fr.acinq.bitcoin.scalacompat._ import fr.acinq.eclair.TestConstants.{Alice, Bob} import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.WatchFundingSpentTriggered import fr.acinq.eclair.channel.fsm.Channel -import fr.acinq.eclair.channel.states.ChannelStateTestsBase.FakeTxPublisherFactory +import fr.acinq.eclair.channel.states.ChannelStateTestsBase.{FakeTxPublisherFactory, PimpTestFSM} import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags} import fr.acinq.eclair.router.Announcements import fr.acinq.eclair.wire.protocol.{ChannelReestablish, ChannelUpdate, CommitSig, Error, Init, RevokeAndAck} @@ -81,7 +81,7 @@ class RestoreSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Chan awaitCond(newAlice.stateName == WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT) // bob is nice and publishes its commitment - val bobCommitTx = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.fullySignedLocalCommitTx(bob.underlyingActor.channelKeys).toTry.get + val bobCommitTx = bob.signCommitTx() // actual tests starts here: let's see what we can do with Bob's commit tx sender.send(newAlice, WatchFundingSpentTriggered(bobCommitTx)) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala index c189f455e6..d4da4f2d9c 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala @@ -35,6 +35,7 @@ import fr.acinq.eclair.channel.fsm.Channel import fr.acinq.eclair.channel.publish.ReplaceableTxPublisher.{Publish, Stop, UpdateConfirmationTarget} import fr.acinq.eclair.channel.publish.TxPublisher.TxRejectedReason._ import fr.acinq.eclair.channel.publish.TxPublisher._ +import fr.acinq.eclair.channel.states.ChannelStateTestsBase.PimpTestFSM import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags} import fr.acinq.eclair.crypto.keymanager.LocalOnChainKeyManager import fr.acinq.eclair.testutils.PimpTestProbe.convert @@ -188,8 +189,8 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w def closeChannelWithoutHtlcs(f: Fixture, overrideCommitTarget: BlockHeight): (PublishFinalTx, PublishReplaceableTx) = { import f._ + val commitTx = alice.signCommitTx() val commitment = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest - val commitTx = commitment.fullySignedLocalCommitTx(alice.underlyingActor.channelKeys).toTry.get val commitFee = commitment.capacity - commitTx.txOut.map(_.amount).sum probe.send(alice, CMD_FORCECLOSE(probe.ref)) probe.expectMsgType[CommandSuccess[CMD_FORCECLOSE]] @@ -207,7 +208,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w def remoteCloseChannelWithoutHtlcs(f: Fixture, overrideCommitTarget: BlockHeight): (Transaction, PublishReplaceableTx) = { import f._ - val commitTx = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.fullySignedLocalCommitTx(bob.underlyingActor.channelKeys).toTry.get + val commitTx = bob.signCommitTx() wallet.publishTransaction(commitTx).pipeTo(probe.ref) probe.expectMsg(commitTx.txid) probe.send(alice, WatchFundingSpentTriggered(commitTx)) @@ -310,7 +311,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx()) { f => import f._ - val remoteCommit = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.fullySignedLocalCommitTx(bob.underlyingActor.channelKeys).toTry.get + val remoteCommit = bob.signCommitTx() assert(remoteCommit.txOut.length == 4) // 2 main outputs + 2 anchor outputs val (_, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 12) wallet.publishTransaction(remoteCommit).pipeTo(probe.ref) @@ -340,7 +341,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.nextRemoteCommit_opt.nonEmpty) val nextRemoteCommitTxId = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.nextRemoteCommit_opt.get.commit.txId - val nextRemoteCommit = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.fullySignedLocalCommitTx(bob.underlyingActor.channelKeys).toTry.get + val nextRemoteCommit = bob.signCommitTx() assert(nextRemoteCommit.txid == nextRemoteCommitTxId) assert(nextRemoteCommit.txOut.length == 5) // 2 main outputs + 2 anchor outputs + 1 htlc val (_, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 12) @@ -360,7 +361,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx()) { f => import f._ - val remoteCommit = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.fullySignedLocalCommitTx(bob.underlyingActor.channelKeys).toTry.get + val remoteCommit = bob.signCommitTx() val (_, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 12) wallet.publishTransaction(remoteCommit).pipeTo(probe.ref) probe.expectMsg(remoteCommit.txid) @@ -377,7 +378,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx()) { f => import f._ - val remoteCommit = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.fullySignedLocalCommitTx(bob.underlyingActor.channelKeys).toTry.get + val remoteCommit = bob.signCommitTx() assert(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localCommit.spec.commitTxFeerate == FeeratePerKw(2500 sat)) // We lower the feerate to make it easy to replace our commit tx by theirs in the mempool. @@ -609,7 +610,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx()) { f => import f._ - val commitTx = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.fullySignedLocalCommitTx(bob.underlyingActor.channelKeys).toTry.get + val commitTx = bob.signCommitTx() // Note that we don't publish the remote commit, to simulate the case where the watch triggers but the remote commit is then evicted from our mempool. probe.send(alice, WatchFundingSpentTriggered(commitTx)) val publishAnchor = alice2blockchain.expectMsgType[PublishReplaceableTx] @@ -969,7 +970,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w assert(htlcTimeout.txInfo.isInstanceOf[HtlcTimeoutTx]) // The remote commit tx has a few confirmations, but isn't deeply confirmed yet. - val remoteCommitTx = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.fullySignedLocalCommitTx(bob.underlyingActor.channelKeys).toTry.get + val remoteCommitTx = bob.signCommitTx() wallet.publishTransaction(remoteCommitTx).pipeTo(probe.ref) probe.expectMsg(remoteCommitTx.txid) generateBlocks(2) @@ -1039,7 +1040,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w assert(htlcTimeout.txInfo.isInstanceOf[HtlcTimeoutTx]) // Ensure remote commit tx confirms. - val nextRemoteCommitTx = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.fullySignedLocalCommitTx(bob.underlyingActor.channelKeys).toTry.get + val nextRemoteCommitTx = bob.signCommitTx() assert(nextRemoteCommitTx.txid == nextRemoteCommitTxId) assert(nextRemoteCommitTx.txOut.length == 6) // 2 main outputs + 2 anchor outputs + 2 htlcs wallet.publishTransaction(nextRemoteCommitTx).pipeTo(probe.ref) @@ -1069,8 +1070,8 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w probe.expectMsgType[CommandSuccess[CMD_FULFILL_HTLC]] // Force-close channel and verify txs sent to watcher. + val commitTx = alice.signCommitTx() val commitment = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest - val commitTx = commitment.fullySignedLocalCommitTx(alice.underlyingActor.channelKeys).toTry.get val commitFee = commitment.capacity - commitTx.txOut.map(_.amount).sum assert(commitTx.txOut.size == 6) probe.send(alice, CMD_FORCECLOSE(probe.ref)) @@ -1525,8 +1526,8 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w probe.expectMsgType[CommandSuccess[CMD_FULFILL_HTLC]] // Force-close channel. - val localCommitTx = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.fullySignedLocalCommitTx(alice.underlyingActor.channelKeys).toTry.get - val remoteCommitTx = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.fullySignedLocalCommitTx(bob.underlyingActor.channelKeys).toTry.get + val localCommitTx = alice.signCommitTx() + val remoteCommitTx = bob.signCommitTx() assert(remoteCommitTx.txOut.size == 6) probe.send(alice, WatchFundingSpentTriggered(remoteCommitTx)) alice2blockchain.expectMsgType[PublishReplaceableTx] // claim anchor @@ -1600,7 +1601,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w probe.expectMsgType[CommandSuccess[CMD_FULFILL_HTLC]] // Force-close channel and verify txs sent to watcher. - val remoteCommitTx = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.fullySignedLocalCommitTx(bob.underlyingActor.channelKeys).toTry.get + val remoteCommitTx = bob.signCommitTx() bob.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.commitmentFormat match { case Transactions.DefaultCommitmentFormat => assert(remoteCommitTx.txOut.size == 4) case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => assert(remoteCommitTx.txOut.size == 6) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala index ff65bf36ac..84aba30cf7 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala @@ -788,7 +788,7 @@ object ChannelStateTestsBase { def commitments: Commitments = channel.stateData.asInstanceOf[ChannelDataWithCommitments].commitments - def signCommitTx(): Transaction = commitments.latest.fullySignedLocalCommitTx(channel.underlyingActor.channelKeys).toTry.get + def signCommitTx(): Transaction = commitments.latest.fullySignedLocalCommitTx(channel.underlyingActor.channelKeys) def htlcTxs(): Seq[UnsignedHtlcTx] = commitments.latest.htlcTxs(channel.underlyingActor.channelKeys).map(_._1) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala index 732b22ebb9..3f958957a1 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala @@ -43,7 +43,6 @@ import fr.acinq.eclair.testutils.PimpTestProbe.convert import fr.acinq.eclair.transactions.DirectedHtlc.{incoming, outgoing} import fr.acinq.eclair.transactions.Transactions import fr.acinq.eclair.transactions.Transactions._ -import fr.acinq.eclair.wire.protocol import fr.acinq.eclair.wire.protocol._ import org.scalatest.Inside.inside import org.scalatest.funsuite.FixtureAnyFunSuiteLike @@ -3136,7 +3135,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik // Bob publishes his commit tx for the first splice transaction (which double-spends the second splice transaction). val bobCommitments = bob.stateData.asInstanceOf[ChannelDataWithCommitments].commitments val previousCommitment = bobCommitments.active.find(_.fundingTxIndex == 1).get - val bobCommitTx1 = previousCommitment.fullySignedLocalCommitTx(bobCommitments.channelParams, bob.underlyingActor.channelKeys).toTry.get + val bobCommitTx1 = previousCommitment.fullySignedLocalCommitTx(bobCommitments.channelParams, bob.underlyingActor.channelKeys) val bobHtlcTxs = previousCommitment.htlcTxs(bobCommitments.channelParams, bob.underlyingActor.channelKeys).map(_._1) Transaction.correctlySpends(bobCommitTx1, Seq(fundingTx1), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) alice ! WatchFundingSpentTriggered(bobCommitTx1) @@ -3575,12 +3574,12 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik crossSign(bob, alice, bob2alice, alice2bob) val aliceCommitments1 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments aliceCommitments1.active.foreach { c => - val commitTx = c.fullySignedLocalCommitTx(aliceCommitments1.channelParams, alice.underlyingActor.channelKeys).toTry.get + val commitTx = c.fullySignedLocalCommitTx(aliceCommitments1.channelParams, alice.underlyingActor.channelKeys) Transaction.correctlySpends(commitTx, Map(c.fundingInput -> c.commitInput(alice.underlyingActor.channelKeys).txOut), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) } val bobCommitments1 = bob.stateData.asInstanceOf[DATA_NORMAL].commitments bobCommitments1.active.foreach { c => - val commitTx = c.fullySignedLocalCommitTx(bobCommitments1.channelParams, bob.underlyingActor.channelKeys).toTry.get + val commitTx = c.fullySignedLocalCommitTx(bobCommitments1.channelParams, bob.underlyingActor.channelKeys) Transaction.correctlySpends(commitTx, Map(c.fundingInput -> c.commitInput(bob.underlyingActor.channelKeys).txOut), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) } @@ -3589,12 +3588,12 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik crossSign(alice, bob, alice2bob, bob2alice) val aliceCommitments2 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments aliceCommitments2.active.foreach { c => - val commitTx = c.fullySignedLocalCommitTx(aliceCommitments2.channelParams, alice.underlyingActor.channelKeys).toTry.get + val commitTx = c.fullySignedLocalCommitTx(aliceCommitments2.channelParams, alice.underlyingActor.channelKeys) Transaction.correctlySpends(commitTx, Map(c.fundingInput -> c.commitInput(alice.underlyingActor.channelKeys).txOut), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) } val bobCommitments2 = bob.stateData.asInstanceOf[DATA_NORMAL].commitments bobCommitments2.active.foreach { c => - val commitTx = c.fullySignedLocalCommitTx(bobCommitments2.channelParams, bob.underlyingActor.channelKeys).toTry.get + val commitTx = c.fullySignedLocalCommitTx(bobCommitments2.channelParams, bob.underlyingActor.channelKeys) Transaction.correctlySpends(commitTx, Map(c.fundingInput -> c.commitInput(bob.underlyingActor.channelKeys).txOut), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/integration/ChannelIntegrationSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/integration/ChannelIntegrationSpec.scala index ec0ad0586e..0350fead75 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/integration/ChannelIntegrationSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/integration/ChannelIntegrationSpec.scala @@ -404,7 +404,7 @@ abstract class ChannelIntegrationSpec extends IntegrationSpec { // we prepare the revoked transactions F will publish val channelKeysF = nodes("F").nodeParams.channelKeyManager.channelKeys(commitmentsF.channelParams.channelConfig, commitmentsF.localChannelParams.fundingKeyPath) val commitmentKeysF = commitmentsF.latest.localKeys(channelKeysF) - val revokedCommitTx = commitmentsF.latest.fullySignedLocalCommitTx(channelKeysF).toTry.get + val revokedCommitTx = commitmentsF.latest.fullySignedLocalCommitTx(channelKeysF) // in this commitment, both parties should have a main output, there are four pending htlcs and anchor outputs if applicable commitmentFormat match { case Transactions.DefaultCommitmentFormat => assert(revokedCommitTx.txOut.size == 6) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerConnectionSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerConnectionSpec.scala index 9bf2b676f8..33801cdb8a 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerConnectionSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerConnectionSpec.scala @@ -357,8 +357,8 @@ class PeerConnectionSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wi // We receive a batch of commit_sig messages from a first channel. val channelId1 = randomBytes32() val commitSigs1 = Seq( - CommitSig(channelId1, IndividualSignature(randomBytes64()), Nil, TlvStream(CommitSigTlv.BatchTlv(2))), - CommitSig(channelId1, IndividualSignature(randomBytes64()), Nil, TlvStream(CommitSigTlv.BatchTlv(2))), + CommitSig(channelId1, IndividualSignature(randomBytes64()), Nil, batchSize = 2), + CommitSig(channelId1, IndividualSignature(randomBytes64()), Nil, batchSize = 2), ) transport.send(peerConnection, commitSigs1.head) transport.expectMsg(TransportHandler.ReadAck(commitSigs1.head)) @@ -370,9 +370,9 @@ class PeerConnectionSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wi // We receive a batch of commit_sig messages from a second channel. val channelId2 = randomBytes32() val commitSigs2 = Seq( - CommitSig(channelId2, IndividualSignature(randomBytes64()), Nil, TlvStream(CommitSigTlv.BatchTlv(3))), - CommitSig(channelId2, IndividualSignature(randomBytes64()), Nil, TlvStream(CommitSigTlv.BatchTlv(3))), - CommitSig(channelId2, IndividualSignature(randomBytes64()), Nil, TlvStream(CommitSigTlv.BatchTlv(3))), + CommitSig(channelId2, IndividualSignature(randomBytes64()), Nil, batchSize = 3), + CommitSig(channelId2, IndividualSignature(randomBytes64()), Nil, batchSize = 3), + CommitSig(channelId2, IndividualSignature(randomBytes64()), Nil, batchSize = 3), ) commitSigs2.dropRight(1).foreach(commitSig => { transport.send(peerConnection, commitSig) @@ -385,8 +385,8 @@ class PeerConnectionSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wi // We receive another batch of commit_sig messages from the first channel, with unrelated messages in the batch. val commitSigs3 = Seq( - CommitSig(channelId1, IndividualSignature(randomBytes64()), Nil, TlvStream(CommitSigTlv.BatchTlv(2))), - CommitSig(channelId1, IndividualSignature(randomBytes64()), Nil, TlvStream(CommitSigTlv.BatchTlv(2))), + CommitSig(channelId1, IndividualSignature(randomBytes64()), Nil, batchSize = 2), + CommitSig(channelId1, IndividualSignature(randomBytes64()), Nil, batchSize = 2), ) transport.send(peerConnection, commitSigs3.head) transport.expectMsg(TransportHandler.ReadAck(commitSigs3.head)) @@ -406,9 +406,9 @@ class PeerConnectionSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wi // We start receiving a batch of commit_sig messages from the first channel, interleaved with a batch from the second // channel, which is not supported. val commitSigs4 = Seq( - CommitSig(channelId1, IndividualSignature(randomBytes64()), Nil, TlvStream(CommitSigTlv.BatchTlv(2))), - CommitSig(channelId2, IndividualSignature(randomBytes64()), Nil, TlvStream(CommitSigTlv.BatchTlv(2))), - CommitSig(channelId2, IndividualSignature(randomBytes64()), Nil, TlvStream(CommitSigTlv.BatchTlv(2))), + CommitSig(channelId1, IndividualSignature(randomBytes64()), Nil, batchSize = 2), + CommitSig(channelId2, IndividualSignature(randomBytes64()), Nil, batchSize = 2), + CommitSig(channelId2, IndividualSignature(randomBytes64()), Nil, batchSize = 2), ) transport.send(peerConnection, commitSigs4.head) transport.expectMsg(TransportHandler.ReadAck(commitSigs4.head)) @@ -421,7 +421,7 @@ class PeerConnectionSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wi peer.expectMsg(CommitSigBatch(commitSigs4.tail)) // We receive a batch that exceeds our threshold: we process them individually. - val invalidCommitSigs = (0 until 30).map(_ => CommitSig(channelId2, IndividualSignature(randomBytes64()), Nil, TlvStream(CommitSigTlv.BatchTlv(30)))) + val invalidCommitSigs = (0 until 30).map(_ => CommitSig(channelId2, IndividualSignature(randomBytes64()), Nil, batchSize = 30)) invalidCommitSigs.foreach(commitSig => { transport.send(peerConnection, commitSig) transport.expectMsg(TransportHandler.ReadAck(commitSig)) 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 993921da93..6928e0bd93 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 @@ -168,7 +168,7 @@ class LightningMessageCodecsSpec extends AnyFunSuite { hex"0088" ++ channelId ++ hex"0001020304050607 0809aabbccddeeff" ++ key.value ++ point.value ++ hex"fe47010000 07 bbbbbbbbbbbbbb" -> ChannelReestablish(channelId, 0x01020304050607L, 0x0809aabbccddeeffL, key, point, TlvStream[ChannelReestablishTlv](Set.empty[ChannelReestablishTlv], Set(GenericTlv(tlvTag, hex"bbbbbbbbbbbbbb")))), hex"0084" ++ channelId ++ signature ++ hex"0000" -> CommitSig(channelId, IndividualSignature(signature), Nil), - hex"0084" ++ channelId ++ ByteVector64.Zeroes ++ hex"0000" ++ hex"02 62" ++ partialSig ++ ByteVector(nonce.toByteArray) -> CommitSig(channelId, PartialSignatureWithNonce(partialSig, nonce), Nil), + hex"0084" ++ channelId ++ ByteVector64.Zeroes ++ hex"0000" ++ hex"02 62" ++ partialSig ++ ByteVector(nonce.toByteArray) -> CommitSig(channelId, PartialSignatureWithNonce(partialSig, nonce), Nil, batchSize = 1), hex"0084" ++ channelId ++ signature ++ hex"0000 fe47010000 00" -> CommitSig(channelId, IndividualSignature(signature), Nil, TlvStream[CommitSigTlv](Set.empty[CommitSigTlv], Set(GenericTlv(tlvTag, ByteVector.empty)))), hex"0084" ++ channelId ++ signature ++ hex"0000 fe47010000 07 cccccccccccccc" -> CommitSig(channelId, IndividualSignature(signature), Nil, TlvStream[CommitSigTlv](Set.empty[CommitSigTlv], Set(GenericTlv(tlvTag, hex"cccccccccccccc")))), From e15c6bc7381116df93595872805b508b065ced36 Mon Sep 17 00:00:00 2001 From: t-bast Date: Mon, 4 Aug 2025 15:32:05 +0200 Subject: [PATCH 12/18] Rename phoenix taproot commitment format and channel type We're tweaking the official taproot channel type for Phoenix by using HTLC transactions that have the same feerate as the commit tx. This will only be support for Phoenix users, so we rename those cases to explicitly mention that this is for Phoenix. We also more explicitly only support upgrades of the commitment format to this specific commitment format during a splice: this isn't a BOLT mechanism so we can simply ignore malicious nodes who try to update to something different. We can change this in the future when this becomes a standard mechanism. --- .../blockchain/fee/OnChainFeeConf.scala | 10 +++++----- .../eclair/channel/ChannelFeatures.scala | 18 ++++++++--------- .../fr/acinq/eclair/channel/fsm/Channel.scala | 20 +++++++++++-------- .../eclair/transactions/CommitmentSpec.scala | 5 +++-- .../eclair/transactions/Transactions.scala | 5 +++-- .../channel/version5/ChannelCodecs5.scala | 2 +- .../ChannelStateTestsHelperMethods.scala | 18 ++++++++--------- .../a/WaitForAcceptChannelStateSpec.scala | 12 +++++------ .../a/WaitForOpenChannelStateSpec.scala | 12 +++++------ .../WaitForDualFundingCreatedStateSpec.scala | 2 +- .../b/WaitForDualFundingSignedStateSpec.scala | 8 ++++---- .../b/WaitForFundingSignedStateSpec.scala | 18 +---------------- .../states/e/NormalSplicesStateSpec.scala | 14 ++++++------- .../channel/states/e/NormalStateSpec.scala | 10 +++++----- .../states/g/NegotiatingStateSpec.scala | 6 +----- .../channel/states/h/ClosingStateSpec.scala | 6 +++--- .../transactions/TransactionsSpec.scala | 6 +++--- 17 files changed, 79 insertions(+), 93 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/fee/OnChainFeeConf.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/fee/OnChainFeeConf.scala index 6ba1dd6b74..0c8b958546 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/fee/OnChainFeeConf.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/fee/OnChainFeeConf.scala @@ -20,7 +20,7 @@ import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.bitcoin.scalacompat.Satoshi import fr.acinq.eclair.BlockHeight import fr.acinq.eclair.transactions.Transactions -import fr.acinq.eclair.transactions.Transactions.{CommitmentFormat, LegacySimpleTaprootChannelCommitmentFormat, UnsafeLegacyAnchorOutputsCommitmentFormat, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat} +import fr.acinq.eclair.transactions.Transactions._ // @formatter:off sealed trait ConfirmationPriority extends Ordered[ConfirmationPriority] { @@ -76,8 +76,8 @@ case class FeerateTolerance(ratioLow: Double, ratioHigh: Double, anchorOutputMax def isProposedFeerateTooHigh(commitmentFormat: CommitmentFormat, networkFeerate: FeeratePerKw, proposedFeerate: FeeratePerKw): Boolean = { commitmentFormat match { - case Transactions.DefaultCommitmentFormat => networkFeerate * ratioHigh < proposedFeerate - case ZeroFeeHtlcTxAnchorOutputsCommitmentFormat | UnsafeLegacyAnchorOutputsCommitmentFormat | LegacySimpleTaprootChannelCommitmentFormat | ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat => networkFeerate * ratioHigh < proposedFeerate + case Transactions.DefaultCommitmentFormat => networkFeerate * ratioHigh < proposedFeerate + case ZeroFeeHtlcTxAnchorOutputsCommitmentFormat | UnsafeLegacyAnchorOutputsCommitmentFormat | PhoenixSimpleTaprootChannelCommitmentFormat | ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat => networkFeerate * ratioHigh < proposedFeerate } } @@ -85,7 +85,7 @@ case class FeerateTolerance(ratioLow: Double, ratioHigh: Double, anchorOutputMax commitmentFormat match { case Transactions.DefaultCommitmentFormat => proposedFeerate < networkFeerate * ratioLow // When using anchor outputs, we allow low feerates: fees will be set with CPFP and RBF at broadcast time. - case ZeroFeeHtlcTxAnchorOutputsCommitmentFormat | UnsafeLegacyAnchorOutputsCommitmentFormat | LegacySimpleTaprootChannelCommitmentFormat | ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat => false + case ZeroFeeHtlcTxAnchorOutputsCommitmentFormat | UnsafeLegacyAnchorOutputsCommitmentFormat | PhoenixSimpleTaprootChannelCommitmentFormat | ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat => false } } } @@ -122,7 +122,7 @@ case class OnChainFeeConf(feeTargets: FeeTargets, commitmentFormat match { case Transactions.DefaultCommitmentFormat => networkFeerate - case _: Transactions.AnchorOutputsCommitmentFormat | _: Transactions.SimpleTaprootChannelCommitmentFormat=> + case _: Transactions.AnchorOutputsCommitmentFormat | _: Transactions.SimpleTaprootChannelCommitmentFormat => val targetFeerate = networkFeerate.min(feerateToleranceFor(remoteNodeId).anchorOutputMaxCommitFeerate) // We make sure the feerate is always greater than the propagation threshold. targetFeerate.max(networkMinFee * 1.25) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelFeatures.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelFeatures.scala index 0ca53a7ea8..392683640c 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelFeatures.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelFeatures.scala @@ -16,7 +16,7 @@ package fr.acinq.eclair.channel -import fr.acinq.eclair.transactions.Transactions.{CommitmentFormat, DefaultCommitmentFormat, LegacySimpleTaprootChannelCommitmentFormat, SimpleTaprootChannelCommitmentFormat, UnsafeLegacyAnchorOutputsCommitmentFormat, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat} +import fr.acinq.eclair.transactions.Transactions._ import fr.acinq.eclair.{ChannelTypeFeature, FeatureSupport, Features, InitFeature, PermanentChannelFeature} /** @@ -122,7 +122,7 @@ object ChannelTypes { override def commitmentFormat: CommitmentFormat = ZeroFeeHtlcTxAnchorOutputsCommitmentFormat override def toString: String = s"anchor_outputs_zero_fee_htlc_tx${if (scidAlias) "+scid_alias" else ""}${if (zeroConf) "+zeroconf" else ""}" } - case class SimpleTaprootChannelsStagingLegacy(scidAlias: Boolean = false, zeroConf: Boolean = false) extends SupportedChannelType { + case class SimpleTaprootChannelsPhoenix(scidAlias: Boolean = false, zeroConf: Boolean = false) extends SupportedChannelType { /** Known channel-type features */ override def features: Set[ChannelTypeFeature] = Set( if (scidAlias) Some(Features.ScidAlias) else None, @@ -130,8 +130,8 @@ object ChannelTypes { Some(Features.SimpleTaprootChannelsPhoenix), ).flatten override def paysDirectlyToWallet: Boolean = false - override def commitmentFormat: CommitmentFormat = LegacySimpleTaprootChannelCommitmentFormat - override def toString: String = s"simple_taproot_channel_staging_legacy${if (scidAlias) "+scid_alias" else ""}${if (zeroConf) "+zeroconf" else ""}" + override def commitmentFormat: CommitmentFormat = PhoenixSimpleTaprootChannelCommitmentFormat + override def toString: String = s"simple_taproot_channel_phoenix${if (scidAlias) "+scid_alias" else ""}${if (zeroConf) "+zeroconf" else ""}" } case class SimpleTaprootChannelsStaging(scidAlias: Boolean = false, zeroConf: Boolean = false) extends SupportedChannelType { /** Known channel-type features */ @@ -168,10 +168,10 @@ object ChannelTypes { AnchorOutputsZeroFeeHtlcTx(zeroConf = true), AnchorOutputsZeroFeeHtlcTx(scidAlias = true), AnchorOutputsZeroFeeHtlcTx(scidAlias = true, zeroConf = true), - SimpleTaprootChannelsStagingLegacy(), - SimpleTaprootChannelsStagingLegacy(zeroConf = true), - SimpleTaprootChannelsStagingLegacy(scidAlias = true), - SimpleTaprootChannelsStagingLegacy(scidAlias = true, zeroConf = true), + SimpleTaprootChannelsPhoenix(), + SimpleTaprootChannelsPhoenix(zeroConf = true), + SimpleTaprootChannelsPhoenix(scidAlias = true), + SimpleTaprootChannelsPhoenix(scidAlias = true, zeroConf = true), SimpleTaprootChannelsStaging(), SimpleTaprootChannelsStaging(zeroConf = true), SimpleTaprootChannelsStaging(scidAlias = true), @@ -192,7 +192,7 @@ object ChannelTypes { if (canUse(Features.SimpleTaprootChannelsStaging)) { SimpleTaprootChannelsStaging(scidAlias, zeroConf) } else if (canUse(Features.SimpleTaprootChannelsPhoenix)) { - SimpleTaprootChannelsStagingLegacy(scidAlias, zeroConf) + SimpleTaprootChannelsPhoenix(scidAlias, zeroConf) } else if (canUse(Features.AnchorOutputsZeroFeeHtlcTx)) { AnchorOutputsZeroFeeHtlcTx(scidAlias, zeroConf) } else if (canUse(Features.AnchorOutputs)) { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala index 990bc18ceb..053a0a79fc 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala @@ -1103,8 +1103,11 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall stay() using d.copy(spliceStatus = SpliceStatus.SpliceAborted) sending TxAbort(d.channelId, t.getMessage) case Right(willFund_opt) => log.info(s"accepting splice with remote.in.amount=${msg.fundingContribution} remote.in.push=${msg.pushAmount}") - val nextCommitmentFormat_opt = msg.channelType_opt.collect { - case s: SupportedChannelType => s.commitmentFormat // TODO: validate msg.channelType_opt + // We only support updating phoenix channels to taproot: we ignore other attempts at upgrading the + // commitment format and will simply apply the previous commitment format. + val nextCommitmentFormat = msg.channelType_opt match { + case Some(_: ChannelTypes.SimpleTaprootChannelsPhoenix) if parentCommitment.commitmentFormat == UnsafeLegacyAnchorOutputsCommitmentFormat => PhoenixSimpleTaprootChannelCommitmentFormat + case _ => parentCommitment.commitmentFormat } val spliceAck = SpliceAck(d.channelId, fundingContribution = willFund_opt.map(_.purchase.amount).getOrElse(0 sat), @@ -1113,9 +1116,8 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall requireConfirmedInputs = nodeParams.channelConf.requireConfirmedInputsForDualFunding, willFund_opt = willFund_opt.map(_.willFund), feeCreditUsed_opt = msg.useFeeCredit_opt, - channelType_opt = msg.channelType_opt // TODO: validate msg.channelType_opt + channelType_opt = msg.channelType_opt ) - val commitmentFormat = nextCommitmentFormat_opt.getOrElse(parentCommitment.commitmentFormat) val fundingParams = InteractiveTxParams( channelId = d.channelId, isInitiator = false, @@ -1124,7 +1126,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall sharedInput_opt = Some(SharedFundingInput(channelKeys, parentCommitment)), remoteFundingPubKey = msg.fundingPubKey, localOutputs = Nil, - commitmentFormat = commitmentFormat, + commitmentFormat = nextCommitmentFormat, lockTime = msg.lockTime, dustLimit = parentCommitment.localCommitParams.dustLimit.max(parentCommitment.remoteCommitParams.dustLimit), targetFeerate = msg.feerate, @@ -1163,10 +1165,12 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall case SpliceStatus.SpliceRequested(cmd, spliceInit) => log.info("our peer accepted our splice request and will contribute {} to the funding transaction", msg.fundingContribution) val parentCommitment = d.commitments.latest.commitment - val nextCommitmentFormat_opt = msg.channelType_opt.collect { - case s: SupportedChannelType => s.commitmentFormat // TODO: validate msg.channelType_opt + // We only support updating phoenix channels to taproot: we ignore other attempts at upgrading the + // commitment format and will simply apply the previous commitment format. + val nextCommitmentFormat = msg.channelType_opt match { + case Some(_: ChannelTypes.SimpleTaprootChannelsPhoenix) if parentCommitment.commitmentFormat == UnsafeLegacyAnchorOutputsCommitmentFormat => PhoenixSimpleTaprootChannelCommitmentFormat + case _ => parentCommitment.commitmentFormat } - val nextCommitmentFormat = nextCommitmentFormat_opt.getOrElse(parentCommitment.commitmentFormat) val fundingParams = InteractiveTxParams( channelId = d.channelId, isInitiator = true, diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/CommitmentSpec.scala b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/CommitmentSpec.scala index f414d8d4ef..eb517841ef 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/CommitmentSpec.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/CommitmentSpec.scala @@ -19,7 +19,7 @@ package fr.acinq.eclair.transactions import fr.acinq.bitcoin.scalacompat.{LexicographicalOrdering, SatoshiLong, TxOut} import fr.acinq.eclair.MilliSatoshi import fr.acinq.eclair.blockchain.fee.FeeratePerKw -import fr.acinq.eclair.transactions.Transactions.{CommitmentFormat, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat} +import fr.acinq.eclair.transactions.Transactions.{CommitmentFormat, DefaultCommitmentFormat, PhoenixSimpleTaprootChannelCommitmentFormat, UnsafeLegacyAnchorOutputsCommitmentFormat, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat} import fr.acinq.eclair.wire.protocol._ /** @@ -94,7 +94,8 @@ final case class CommitmentSpec(htlcs: Set[DirectedHtlc], commitTxFeerate: Feera def htlcTxFeerate(commitmentFormat: CommitmentFormat): FeeratePerKw = commitmentFormat match { case ZeroFeeHtlcTxAnchorOutputsCommitmentFormat | ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat => FeeratePerKw(0 sat) - case _ => commitTxFeerate + case UnsafeLegacyAnchorOutputsCommitmentFormat | PhoenixSimpleTaprootChannelCommitmentFormat => commitTxFeerate + case DefaultCommitmentFormat => commitTxFeerate } def findIncomingHtlcById(id: Long): Option[IncomingHtlc] = htlcs.collectFirst { case htlc: IncomingHtlc if htlc.add.id == id => htlc } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala index 85e68b766d..6c62891f0e 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala @@ -192,8 +192,9 @@ object Transactions { override val claimHtlcPenaltyWeight = 396 } - case object LegacySimpleTaprootChannelCommitmentFormat extends SimpleTaprootChannelCommitmentFormat { - override def toString: String = "unsafe_simple_taproot" + /** For Phoenix users we sign HTLC transactions with the same feerate as the commit tx to allow broadcasting without wallet inputs. */ + case object PhoenixSimpleTaprootChannelCommitmentFormat extends SimpleTaprootChannelCommitmentFormat { + override def toString: String = "simple_taproot_phoenix" } case object ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat extends SimpleTaprootChannelCommitmentFormat { 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 eeb2480c5b..956db33f3f 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 @@ -81,7 +81,7 @@ private[channel] object ChannelCodecs5 { .typecase(0x00, provide(Transactions.DefaultCommitmentFormat)) .typecase(0x01, provide(Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat)) .typecase(0x02, provide(Transactions.ZeroFeeHtlcTxAnchorOutputsCommitmentFormat)) - .typecase(0x03, provide(Transactions.LegacySimpleTaprootChannelCommitmentFormat)) + .typecase(0x03, provide(Transactions.PhoenixSimpleTaprootChannelCommitmentFormat)) .typecase(0x04, provide(Transactions.ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat)) private val localChannelParamsCodec: Codec[LocalChannelParams] = ( diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala index 84aba30cf7..61ad6536bd 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala @@ -99,9 +99,9 @@ object ChannelStateTestsTags { val SimpleClose = "option_simple_close" /** If set, disable option_splice for one node. */ val DisableSplice = "disable_splice" - /** If set, channels weill use option_simple_taproot_staging */ - val OptionSimpleTaprootStagingLegacy = "option_simple_taproot_staging_legacy" - val OptionSimpleTaprootStagingZeroFee = "option_simple_taproot_staging_zerofee" + /** If set, channels will use taproot. */ + val OptionSimpleTaprootPhoenix = "option_simple_taproot_phoenix" + val OptionSimpleTaproot = "option_simple_taproot" } trait ChannelStateTestsBase extends Assertions with Eventually { @@ -267,8 +267,8 @@ trait ChannelStateTestsBase extends Assertions with Eventually { .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.ScidAlias))(_.updated(Features.ScidAlias, FeatureSupport.Optional)) .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.DualFunding))(_.updated(Features.DualFunding, FeatureSupport.Optional)) .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.SimpleClose))(_.updated(Features.SimpleClose, FeatureSupport.Optional)) - .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.OptionSimpleTaprootStagingLegacy))(_.updated(Features.SimpleTaprootChannelsPhoenix, FeatureSupport.Optional)) - .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.OptionSimpleTaprootStagingZeroFee))(_.updated(Features.SimpleTaprootChannelsStaging, FeatureSupport.Optional)) + .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.OptionSimpleTaprootPhoenix))(_.updated(Features.SimpleTaprootChannelsPhoenix, FeatureSupport.Optional)) + .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.OptionSimpleTaproot))(_.updated(Features.SimpleTaprootChannelsStaging, FeatureSupport.Optional)) ) val nodeParamsB1 = nodeParamsB.copy(features = nodeParamsB.features .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.DisableWumbo))(_.removed(Features.Wumbo)) @@ -282,8 +282,8 @@ trait ChannelStateTestsBase extends Assertions with Eventually { .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.DualFunding))(_.updated(Features.DualFunding, FeatureSupport.Optional)) .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.SimpleClose))(_.updated(Features.SimpleClose, FeatureSupport.Optional)) .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.DisableSplice))(_.removed(Features.SplicePrototype)) - .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.OptionSimpleTaprootStagingLegacy))(_.updated(Features.SimpleTaprootChannelsPhoenix, FeatureSupport.Optional)) - .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.OptionSimpleTaprootStagingZeroFee))(_.updated(Features.SimpleTaprootChannelsStaging, FeatureSupport.Optional)) + .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.OptionSimpleTaprootPhoenix))(_.updated(Features.SimpleTaprootChannelsPhoenix, FeatureSupport.Optional)) + .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.OptionSimpleTaproot))(_.updated(Features.SimpleTaprootChannelsStaging, FeatureSupport.Optional)) ) (nodeParamsA1, nodeParamsB1) } @@ -297,8 +297,8 @@ trait ChannelStateTestsBase extends Assertions with Eventually { val channelType = ChannelTypes.defaultFromFeatures(aliceInitFeatures, bobInitFeatures, announceChannel = channelFlags.announceChannel) // those features can only be enabled with AnchorOutputsZeroFeeHtlcTxs, this is to prevent incompatible test configurations - if (tags.contains(ChannelStateTestsTags.ZeroConf)) assert(tags.contains(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs) || tags.contains(ChannelStateTestsTags.OptionSimpleTaprootStagingZeroFee), "invalid test configuration") - if (tags.contains(ChannelStateTestsTags.ScidAlias)) assert(tags.contains(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs) || tags.contains(ChannelStateTestsTags.OptionSimpleTaprootStagingZeroFee), "invalid test configuration") + if (tags.contains(ChannelStateTestsTags.ZeroConf)) assert(tags.contains(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs) || tags.contains(ChannelStateTestsTags.OptionSimpleTaproot), "invalid test configuration") + if (tags.contains(ChannelStateTestsTags.ScidAlias)) assert(tags.contains(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs) || tags.contains(ChannelStateTestsTags.OptionSimpleTaproot), "invalid test configuration") implicit val ec: scala.concurrent.ExecutionContext = scala.concurrent.ExecutionContext.global val aliceChannelParams = Alice.channelParams diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptChannelStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptChannelStateSpec.scala index 4436b8e652..5a8a3d13a8 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptChannelStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForAcceptChannelStateSpec.scala @@ -26,7 +26,7 @@ import fr.acinq.eclair.channel.fsm.Channel import fr.acinq.eclair.channel.fsm.Channel.TickChannelOpenTimeout import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags} import fr.acinq.eclair.io.Peer.OpenChannelResponse -import fr.acinq.eclair.transactions.Transactions.{DefaultCommitmentFormat, LegacySimpleTaprootChannelCommitmentFormat, UnsafeLegacyAnchorOutputsCommitmentFormat, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat} +import fr.acinq.eclair.transactions.Transactions.{DefaultCommitmentFormat, PhoenixSimpleTaprootChannelCommitmentFormat, UnsafeLegacyAnchorOutputsCommitmentFormat, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat} import fr.acinq.eclair.wire.protocol.{AcceptChannel, ChannelTlv, Error, OpenChannel, TlvStream} import fr.acinq.eclair.{CltvExpiryDelta, TestConstants, TestKitBaseClass} import org.scalatest.funsuite.FixtureAnyFunSuiteLike @@ -108,21 +108,21 @@ class WaitForAcceptChannelStateSpec extends TestKitBaseClass with FixtureAnyFunS aliceOpenReplyTo.expectNoMessage() } - test("recv AcceptChannel (simple taproot channels outputs)", Tag(ChannelStateTestsTags.OptionSimpleTaprootStagingLegacy)) { f => + test("recv AcceptChannel (simple taproot channels phoenix)", Tag(ChannelStateTestsTags.OptionSimpleTaprootPhoenix)) { f => import f._ val accept = bob2alice.expectMsgType[AcceptChannel] - assert(accept.channelType_opt.contains(ChannelTypes.SimpleTaprootChannelsStagingLegacy())) + assert(accept.channelType_opt.contains(ChannelTypes.SimpleTaprootChannelsPhoenix())) assert(accept.commitNonce_opt.isDefined) bob2alice.forward(alice) awaitCond(alice.stateName == WAIT_FOR_FUNDING_INTERNAL) - assert(alice.stateData.asInstanceOf[DATA_WAIT_FOR_FUNDING_INTERNAL].commitmentFormat == LegacySimpleTaprootChannelCommitmentFormat) + assert(alice.stateData.asInstanceOf[DATA_WAIT_FOR_FUNDING_INTERNAL].commitmentFormat == PhoenixSimpleTaprootChannelCommitmentFormat) aliceOpenReplyTo.expectNoMessage() } - test("recv AcceptChannel (simple taproot channels outputs, missing nonce)", Tag(ChannelStateTestsTags.OptionSimpleTaprootStagingLegacy)) { f => + test("recv AcceptChannel (simple taproot channels outputs, missing nonce)", Tag(ChannelStateTestsTags.OptionSimpleTaprootPhoenix)) { f => import f._ val accept = bob2alice.expectMsgType[AcceptChannel] - assert(accept.channelType_opt.contains(ChannelTypes.SimpleTaprootChannelsStagingLegacy())) + assert(accept.channelType_opt.contains(ChannelTypes.SimpleTaprootChannelsPhoenix())) assert(accept.commitNonce_opt.isDefined) bob2alice.forward(alice, accept.copy(tlvStream = accept.tlvStream.copy(records = accept.tlvStream.records.filterNot(_.isInstanceOf[ChannelTlv.NextLocalNonceTlv])))) alice2bob.expectMsg(Error(accept.temporaryChannelId, MissingCommitNonce(accept.temporaryChannelId, TxId(ByteVector32.Zeroes), 0).getMessage)) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenChannelStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenChannelStateSpec.scala index 3c98e5fc45..47369f4464 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenChannelStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/a/WaitForOpenChannelStateSpec.scala @@ -24,7 +24,7 @@ import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fsm.Channel import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags} -import fr.acinq.eclair.transactions.Transactions.{DefaultCommitmentFormat, LegacySimpleTaprootChannelCommitmentFormat, UnsafeLegacyAnchorOutputsCommitmentFormat, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat} +import fr.acinq.eclair.transactions.Transactions.{DefaultCommitmentFormat, UnsafeLegacyAnchorOutputsCommitmentFormat, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat} import fr.acinq.eclair.wire.protocol.{AcceptChannel, ChannelTlv, Error, OpenChannel, TlvStream} import fr.acinq.eclair.{CltvExpiryDelta, MilliSatoshiLong, TestConstants, TestKitBaseClass, ToMilliSatoshiConversion} import org.scalatest.funsuite.FixtureAnyFunSuiteLike @@ -107,20 +107,20 @@ class WaitForOpenChannelStateSpec extends TestKitBaseClass with FixtureAnyFunSui assert(bob.stateData.asInstanceOf[DATA_WAIT_FOR_FUNDING_CREATED].commitmentFormat == DefaultCommitmentFormat) } - test("recv OpenChannel (simple taproot channels)", Tag(ChannelStateTestsTags.OptionSimpleTaprootStagingLegacy)) { f => + test("recv OpenChannel (simple taproot channels)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => import f._ val open = alice2bob.expectMsgType[OpenChannel] - assert(open.channelType_opt.contains(ChannelTypes.SimpleTaprootChannelsStagingLegacy())) + assert(open.channelType_opt.contains(ChannelTypes.SimpleTaprootChannelsStaging())) alice2bob.forward(bob) awaitCond(bob.stateName == WAIT_FOR_FUNDING_CREATED) - assert(bob.stateData.asInstanceOf[DATA_WAIT_FOR_FUNDING_CREATED].commitmentFormat == LegacySimpleTaprootChannelCommitmentFormat) + assert(bob.stateData.asInstanceOf[DATA_WAIT_FOR_FUNDING_CREATED].commitmentFormat == ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) assert(open.commitNonce_opt.isDefined) } - test("recv OpenChannel (simple taproot channels, missing nonce)", Tag(ChannelStateTestsTags.OptionSimpleTaprootStagingLegacy)) { f => + test("recv OpenChannel (simple taproot channels, missing nonce)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => import f._ val open = alice2bob.expectMsgType[OpenChannel] - assert(open.channelType_opt.contains(ChannelTypes.SimpleTaprootChannelsStagingLegacy())) + assert(open.channelType_opt.contains(ChannelTypes.SimpleTaprootChannelsStaging())) assert(open.commitNonce_opt.isDefined) alice2bob.forward(bob, open.copy(tlvStream = open.tlvStream.copy(records = open.tlvStream.records.filterNot(_.isInstanceOf[ChannelTlv.NextLocalNonceTlv])))) val error = bob2alice.expectMsgType[Error] diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForDualFundingCreatedStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForDualFundingCreatedStateSpec.scala index c08aa5fb92..5289c81947 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForDualFundingCreatedStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForDualFundingCreatedStateSpec.scala @@ -262,7 +262,7 @@ class WaitForDualFundingCreatedStateSpec extends TestKitBaseClass with FixtureAn } class WaitForDualFundingCreatedStateWithTaprootChannelsSpec extends WaitForDualFundingCreatedStateSpec { - override val extraTags: Set[String] = Set(ChannelStateTestsTags.OptionSimpleTaprootStagingLegacy) + override val extraTags: Set[String] = Set(ChannelStateTestsTags.OptionSimpleTaproot) test("tx_complete is missing nonces", Tag(ChannelStateTestsTags.DualFunding)) { f => import f._ diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForDualFundingSignedStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForDualFundingSignedStateSpec.scala index dfabc52c65..c18a7266b3 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForDualFundingSignedStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForDualFundingSignedStateSpec.scala @@ -370,7 +370,7 @@ class WaitForDualFundingSignedStateSpec extends TestKitBaseClass with FixtureAny assert(wallet.rolledback.isEmpty) } - test("recv INPUT_DISCONNECTED (commit_sig not received)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.OptionSimpleTaprootStagingLegacy)) { f => + test("recv INPUT_DISCONNECTED (commit_sig not received)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => import f._ val fundingTxId = alice.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_SIGNED].signingSession.fundingTx.txId @@ -405,7 +405,7 @@ class WaitForDualFundingSignedStateSpec extends TestKitBaseClass with FixtureAny reconnect(f, fundingTxId, aliceExpectsCommitSig = false, bobExpectsCommitSig = true) } - test("recv INPUT_DISCONNECTED (commit_sig received by Bob)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.OptionSimpleTaprootStagingLegacy)) { f => + test("recv INPUT_DISCONNECTED (commit_sig received by Bob)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => import f._ val fundingTxId = alice.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_SIGNED].signingSession.fundingTx.txId @@ -424,7 +424,7 @@ class WaitForDualFundingSignedStateSpec extends TestKitBaseClass with FixtureAny reconnect(f, fundingTxId, aliceExpectsCommitSig = true, bobExpectsCommitSig = false) } - test("recv INPUT_DISCONNECTED (commit_sig received by Bob, zero-conf)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.ZeroConf), Tag(ChannelStateTestsTags.OptionSimpleTaprootStagingZeroFee)) { f => + test("recv INPUT_DISCONNECTED (commit_sig received by Bob, zero-conf)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.ZeroConf), Tag(ChannelStateTestsTags.OptionSimpleTaprootPhoenix)) { f => import f._ alice2bob.expectMsgType[CommitSig] @@ -623,5 +623,5 @@ class WaitForDualFundingSignedStateSpec extends TestKitBaseClass with FixtureAny } class WaitForDualFundingSignedStateWithTaprootChannelsSpec extends WaitForDualFundingSignedStateSpec { - override val extraTags: Set[String] = Set(ChannelStateTestsTags.OptionSimpleTaprootStagingLegacy) + override val extraTags: Set[String] = Set(ChannelStateTestsTags.OptionSimpleTaprootPhoenix) } \ No newline at end of file diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForFundingSignedStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForFundingSignedStateSpec.scala index cf1449acb9..eba3d7baff 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForFundingSignedStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForFundingSignedStateSpec.scala @@ -100,23 +100,7 @@ class WaitForFundingSignedStateSpec extends TestKitBaseClass with FixtureAnyFunS aliceOpenReplyTo.expectMsgType[OpenChannelResponse.Created] } - test("recv FundingSigned with valid signature (simple taproot channels legacy)", Tag(ChannelStateTestsTags.OptionSimpleTaprootStagingLegacy)) { f => - import f._ - val listener = TestProbe() - alice.underlying.system.eventStream.subscribe(listener.ref, classOf[TransactionPublished]) - bob2alice.expectMsgType[FundingSigned] - bob2alice.forward(alice) - awaitCond(alice.stateName == WAIT_FOR_FUNDING_CONFIRMED) - val watchConfirmed = alice2blockchain.expectMsgType[WatchFundingConfirmed] - val fundingTxId = watchConfirmed.txId - assert(watchConfirmed.minDepth == 6) - val txPublished = listener.expectMsgType[TransactionPublished] - assert(txPublished.tx.txid == fundingTxId) - assert(txPublished.miningFee > 0.sat) - aliceOpenReplyTo.expectMsgType[OpenChannelResponse.Created] - } - - test("recv FundingSigned with valid signature (simple taproot channels zero fee)", Tag(ChannelStateTestsTags.OptionSimpleTaprootStagingZeroFee)) { f => + test("recv FundingSigned with valid signature (simple taproot channels)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => import f._ val listener = TestProbe() alice.underlying.system.eventStream.subscribe(listener.ref, classOf[TransactionPublished]) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala index 3f958957a1..cad97340e6 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala @@ -357,7 +357,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik assert(finalState.commitments.latest.localCommit.spec.toRemote == 700_000_000.msat - settledHtlcs) } - test("recv CMD_SPLICE (splice-in)", Tag(ChannelStateTestsTags.OptionSimpleTaprootStagingLegacy)) { f => + test("recv CMD_SPLICE (splice-in)", Tag(ChannelStateTestsTags.OptionSimpleTaprootPhoenix)) { f => import f._ val initialState = alice.stateData.asInstanceOf[DATA_NORMAL] @@ -1061,7 +1061,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik test("recv TxAbort (after CommitSig)") { f => import f._ - assume(!this.extraTags.contains(ChannelStateTestsTags.OptionSimpleTaprootStagingLegacy) && !this.extraTags.contains(ChannelStateTestsTags.OptionSimpleTaprootStagingZeroFee) && this.spliceChannelType_opt.isEmpty) + assume(!this.extraTags.contains(ChannelStateTestsTags.OptionSimpleTaprootPhoenix) && !this.extraTags.contains(ChannelStateTestsTags.OptionSimpleTaproot) && this.spliceChannelType_opt.isEmpty) val sender = TestProbe() alice ! CMD_SPLICE(sender.ref, spliceIn_opt = Some(SpliceIn(50_000 sat)), spliceOut_opt = None, requestFunding_opt = None, this.spliceChannelType_opt) @@ -1526,7 +1526,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik awaitAssert(assert(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.all.size == 1)) } - test("recv CMD_ADD_HTLC with multiple commitments", Tag(ChannelStateTestsTags.OptionSimpleTaprootStagingLegacy)) { f => + test("recv CMD_ADD_HTLC with multiple commitments", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => import f._ initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat))) val sender = TestProbe() @@ -2702,7 +2702,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik bob2alice.expectNoMessage(100 millis) } - test("Disconnection after exchanging tx_signatures and both sides send commit_sig for channel update; revoke_and_ack not received", Tag(ChannelStateTestsTags.OptionSimpleTaprootStagingLegacy)) { f => + test("Disconnection after exchanging tx_signatures and both sides send commit_sig for channel update; revoke_and_ack not received", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => import f._ // alice bob // | ... | @@ -3794,17 +3794,17 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik // test taproot channels class NormalSplicesStateWithTaprootChannelsSpec extends NormalSplicesStateSpec { - override val extraTags: Set[String] = Set(ChannelStateTestsTags.OptionSimpleTaprootStagingZeroFee) + override val extraTags: Set[String] = Set(ChannelStateTestsTags.OptionSimpleTaproot) } class NormalSplicesStateWithLegacyTaprootChannelsSpec extends NormalSplicesStateSpec { - override val extraTags: Set[String] = Set(ChannelStateTestsTags.OptionSimpleTaprootStagingLegacy) + override val extraTags: Set[String] = Set(ChannelStateTestsTags.OptionSimpleTaprootPhoenix) } // test migration from anchor outputs to taproot channels during splices class NormalSplicesStateUpgradeToLegacyTaprootChannelsSpec extends NormalSplicesStateSpec { override val extraTags: Set[String] = Set(ChannelStateTestsTags.AnchorOutputs) - override val spliceChannelType_opt: Option[ChannelType] = Some(ChannelTypes.SimpleTaprootChannelsStagingLegacy(scidAlias = false, zeroConf = false)) + override val spliceChannelType_opt: Option[ChannelType] = Some(ChannelTypes.SimpleTaprootChannelsPhoenix(scidAlias = false, zeroConf = false)) } class NormalSplicesStateUpgradeToTaprootChannelsSpec extends NormalSplicesStateSpec { diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala index 9f2905ee5d..352f568405 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala @@ -1055,7 +1055,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.changes.remoteChanges.signed.size == 1) } - test("recv CommitSig (one htlc sent)", Tag(ChannelStateTestsTags.OptionSimpleTaprootStagingLegacy)) { f => + test("recv CommitSig (one htlc sent)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => import f._ val (_, htlc) = addHtlc(50000000 msat, alice, bob, alice2bob, bob2alice) @@ -1130,7 +1130,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localCommit.htlcRemoteSigs.size == 3) } - test("recv CommitSig (multiple htlcs in both directions) (simple taproot channels)", Tag(ChannelStateTestsTags.OptionSimpleTaprootStagingLegacy)) { f => + test("recv CommitSig (multiple htlcs in both directions) (simple taproot channels)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => import f._ addHtlc(50000000 msat, alice, bob, alice2bob, bob2alice) // a->b (regular) @@ -1276,7 +1276,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with bob2blockchain.expectWatchTxConfirmed(tx.txid) } - test("recv CommitSig (simple taproot channels, missing nonce)", Tag(ChannelStateTestsTags.OptionSimpleTaprootStagingLegacy)) { f => + test("recv CommitSig (simple taproot channels, missing nonce)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => import f._ addHtlc(50000000 msat, alice, bob, alice2bob, bob2alice) val tx = bob.signCommitTx() @@ -1295,7 +1295,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with bob2blockchain.expectWatchTxConfirmed(tx.txid) } - test("recv CommitSig (simple taproot channels, invalid partial signature)", Tag(ChannelStateTestsTags.OptionSimpleTaprootStagingLegacy)) { f => + test("recv CommitSig (simple taproot channels, invalid partial signature)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => import f._ addHtlc(50000000 msat, alice, bob, alice2bob, bob2alice) val tx = bob.signCommitTx() @@ -1465,7 +1465,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with alice2blockchain.expectWatchTxConfirmed(tx.txid) } - test("recv RevokeAndAck (simple taproot channels, missing nonce)", Tag(ChannelStateTestsTags.OptionSimpleTaprootStagingLegacy)) { f => + test("recv RevokeAndAck (simple taproot channels, missing nonce)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => import f._ val tx = alice.signCommitTx() addHtlc(50000000 msat, alice, bob, alice2bob, bob2alice) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala index f6f6304357..822cafd7b3 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala @@ -537,11 +537,7 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike `recv ClosingComplete (both outputs)`(f) } - test("recv ClosingComplete (both outputs, simple taproot channels)", Tag(ChannelStateTestsTags.SimpleClose), Tag(ChannelStateTestsTags.OptionSimpleTaprootStagingLegacy)) { f => - `recv ClosingComplete (both outputs)`(f) - } - - test("recv ClosingComplete (both outputs, simple taproot channels zero fee)", Tag(ChannelStateTestsTags.SimpleClose), Tag(ChannelStateTestsTags.OptionSimpleTaprootStagingZeroFee)) { f => + test("recv ClosingComplete (both outputs, simple taproot channels)", Tag(ChannelStateTestsTags.SimpleClose), Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => `recv ClosingComplete (both outputs)`(f) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala index c8ee7b5626..1690a57a79 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala @@ -53,7 +53,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with case class FixtureParam(alice: TestFSMRef[ChannelState, ChannelData, Channel], bob: TestFSMRef[ChannelState, ChannelData, Channel], alice2bob: TestProbe, bob2alice: TestProbe, alice2blockchain: TestProbe, bob2blockchain: TestProbe, alice2relayer: TestProbe, bob2relayer: TestProbe, channelUpdateListener: TestProbe, txListener: TestProbe, eventListener: TestProbe, bobCommitTxs: List[Transaction]) - val extraTags: Set[String] = Set(ChannelStateTestsTags.OptionSimpleTaprootStagingLegacy) + val extraTags: Set[String] = Set(ChannelStateTestsTags.OptionSimpleTaproot) override def withFixture(test: OneArgTest): Outcome = { val tags = test.tags ++ extraTags @@ -2487,9 +2487,9 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with } class ClosingStateWithTaprootChannelsSpec extends ClosingStateSpec { - override val extraTags: Set[String] = Set(ChannelStateTestsTags.OptionSimpleTaprootStagingZeroFee) + override val extraTags: Set[String] = Set(ChannelStateTestsTags.OptionSimpleTaproot) } class ClosingStateWithLegacyTaprootChannelsSpec extends ClosingStateSpec { - override val extraTags: Set[String] = Set(ChannelStateTestsTags.OptionSimpleTaprootStagingLegacy) + override val extraTags: Set[String] = Set(ChannelStateTestsTags.OptionSimpleTaprootPhoenix) } \ No newline at end of file diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TransactionsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TransactionsSpec.scala index c48a75d1c2..ed50512c83 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TransactionsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TransactionsSpec.scala @@ -548,11 +548,11 @@ class TransactionsSpec extends AnyFunSuite with Logging { } test("generate valid commitment and htlc transactions (simple taproot channels)") { - testCommitAndHtlcTxs(LegacySimpleTaprootChannelCommitmentFormat) + testCommitAndHtlcTxs(ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) } - test("generate valid commitment and htlc transactions (zero fee simple taproot channels)") { - testCommitAndHtlcTxs(ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) + test("generate valid commitment and htlc transactions (phoenix simple taproot channels)") { + testCommitAndHtlcTxs(PhoenixSimpleTaprootChannelCommitmentFormat) } test("generate taproot NUMS point") { From db5da815b8e9e35e87a3f82a61aa5b7b6bcd1f21 Mon Sep 17 00:00:00 2001 From: t-bast Date: Mon, 4 Aug 2025 17:18:37 +0200 Subject: [PATCH 13/18] Refactor and simplify interactive-tx for taproot We refactor the `InteractiveTxBuilder` to: - leverage existing signing helpers (from `Transactions.SpliceTx`) - revert unnecessary changes (`receiveInput()`) - add more documentation around `tx_complete` nonce management - add early nonce validation (in `validateTx`) - clean-up error handling --- .../channel/fund/InteractiveTxBuilder.scala | 297 +++++++++--------- 1 file changed, 143 insertions(+), 154 deletions(-) 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 5257f0c6a9..1ea8a92366 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 @@ -25,10 +25,10 @@ import fr.acinq.bitcoin.ScriptFlags import fr.acinq.bitcoin.crypto.musig2.IndividualNonce import fr.acinq.bitcoin.psbt.Psbt import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} -import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, LexicographicalOrdering, Musig2, OutPoint, Satoshi, SatoshiLong, Script, ScriptWitness, Transaction, TxId, TxIn, TxOut} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, LexicographicalOrdering, OutPoint, Satoshi, SatoshiLong, Script, ScriptWitness, Transaction, TxId, TxIn, TxOut} import fr.acinq.eclair.blockchain.OnChainChannelFunder import fr.acinq.eclair.blockchain.fee.FeeratePerKw -import fr.acinq.eclair.channel.ChannelSpendSignature.{IndividualSignature, PartialSignatureWithNonce} +import fr.acinq.eclair.channel.ChannelSpendSignature.IndividualSignature import fr.acinq.eclair.channel.Helpers.Closing.MutualClose import fr.acinq.eclair.channel.Helpers.Funding import fr.acinq.eclair.channel._ @@ -107,9 +107,19 @@ object InteractiveTxBuilder { case class SharedFundingInput(info: InputInfo, fundingTxIndex: Long, remoteFundingPubkey: PublicKey, commitmentFormat: CommitmentFormat) { val weight: Int = commitmentFormat.fundingInputWeight - def sign(channelKeys: ChannelKeys, tx: Transaction, spentUtxos: Map[OutPoint, TxOut]): IndividualSignature = { + def sign(channelId: ByteVector32, channelKeys: ChannelKeys, tx: Transaction, localNonce_opt: Option[LocalNonce], remoteNonce_opt: Option[IndividualNonce], spentUtxos: Map[OutPoint, TxOut]): Either[ChannelException, ChannelSpendSignature] = { val localFundingKey = channelKeys.fundingKey(fundingTxIndex) - Transactions.SpliceTx(info, tx).sign(localFundingKey, remoteFundingPubkey, spentUtxos) + val spliceTx = Transactions.SpliceTx(info, tx) + commitmentFormat match { + case _: SegwitV0CommitmentFormat => Right(spliceTx.sign(localFundingKey, remoteFundingPubkey, spentUtxos)) + case _: SimpleTaprootChannelCommitmentFormat => (localNonce_opt, remoteNonce_opt) match { + case (Some(localNonce), Some(remoteNonce)) => spliceTx.partialSign(localFundingKey, remoteFundingPubkey, spentUtxos, localNonce, Seq(localNonce.publicNonce, remoteNonce)) match { + case Left(_) => Left(InvalidFundingNonce(channelId, tx.txid)) + case Right(sig) => Right(sig) + } + case _ => Left(MissingFundingNonce(channelId, tx.txid)) + } + } } } @@ -334,14 +344,13 @@ object InteractiveTxBuilder { val remoteFees: MilliSatoshi = remoteAmountIn - remoteAmountOut // Note that the truncation is a no-op: sub-satoshi balances are carried over from inputs to outputs and cancel out. val fees: Satoshi = (localFees + remoteFees).truncateToSatoshi + // Outputs spent by this transaction, in the order in which they appear in the transaction inputs. + val spentOutputs: Seq[TxOut] = (sharedInput_opt.toSeq ++ localInputs ++ remoteInputs).sortBy(_.serialId).map(_.txOut) // When signing transactions that include taproot inputs, we must provide details about all of the transaction's inputs. val inputDetails: Map[OutPoint, TxOut] = (sharedInput_opt.toSeq.map(i => i.outPoint -> i.txOut) ++ localInputs.map(i => i.outPoint -> i.txOut) ++ remoteInputs.map(i => i.outPoint -> i.txOut)).toMap def localOnlyNonChangeOutputs: List[Output.Local.NonChange] = localOutputs.collect { case o: Local.NonChange => o } - // outputs spent by this tx - val spentOutputs: Seq[TxOut] = (sharedInput_opt.toSeq ++ localInputs ++ remoteInputs).sortBy(_.serialId).map(_.txOut) - def buildUnsignedTx(): Transaction = { val sharedTxIn = sharedInput_opt.map(i => (i.serialId, TxIn(i.outPoint, ByteVector.empty, i.sequence))).toSeq val localTxIn = localInputs.map(i => (i.serialId, TxIn(i.outPoint, ByteVector.empty, i.sequence))) @@ -470,12 +479,13 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon case rbf: SpliceTxRbf => rbf.previousTransactions case _ => Nil } - // nonce used to sign the shared input if there is one - private val localNonce_opt = fundingParams.sharedInput_opt.collect { - case s if s.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => - val localPubKeyForSharedInput = channelKeys.fundingKey(s.fundingTxIndex).publicKey - NonceGenerator.signingNonce(localPubKeyForSharedInput, s.remoteFundingPubkey, s.info.outPoint.txid) - } + // Nonce we will use to sign the shared input, if we are splicing a taproot channel. + private val localFundingNonce_opt: Option[LocalNonce] = fundingParams.sharedInput_opt.flatMap(sharedInput => sharedInput.commitmentFormat match { + case _: SegwitV0CommitmentFormat => None + case _: SimpleTaprootChannelCommitmentFormat => + val previousFundingKey = channelKeys.fundingKey(sharedInput.fundingTxIndex).publicKey + Some(NonceGenerator.signingNonce(previousFundingKey, sharedInput.remoteFundingPubkey, sharedInput.info.outPoint.txid)) + }) def start(): Behavior[Command] = { val txFunder = context.spawnAnonymous(InteractiveTxFunder(remoteNodeId, fundingParams, fundingPubkeyScript, purpose, wallet)) @@ -539,22 +549,27 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon receive(next) case Nil => val txComplete = fundingParams.commitmentFormat match { + case _: SegwitV0CommitmentFormat => TxComplete(fundingParams.channelId) case _: SimpleTaprootChannelCommitmentFormat => - // commit nonces are used to sign commit transactions and are sent to our peer. Once this session complete the last one we've received becomes "their next remote nonce" - // we just need the funding tx id, we don't validate its inputs/outputs, if it's invalid we'll send tx_complete again - val fundingTx = Transaction(version = 2, + // We don't have more inputs or outputs to contribute to the shared transaction. + // If our peer doesn't have anything more to contribute either, we will proceed to exchange commitment + // signatures spending this shared transaction, so we need to provide nonces to create those signatures. + // If our peer adds more inputs or outputs, we will simply send a new tx_complete message in response with + // nonces for the updated shared transaction. + // Note that we don't validate the shared transaction at that point: this will be done later once we've + // both sent tx_complete. If the shared transaction is invalid, we will abort and discard our nonces. + val fundingTxId = Transaction( + version = 2, txIn = (session.localInputs.map(i => i.serialId -> TxIn(i.outPoint, Nil, i.sequence)) ++ session.remoteInputs.map(i => i.serialId -> TxIn(i.outPoint, Nil, i.sequence))).sortBy(_._1).map(_._2), txOut = (session.localOutputs.map(o => o.serialId -> TxOut(o.amount, o.pubkeyScript)) ++ session.remoteOutputs.map(o => o.serialId -> TxOut(o.amount, o.pubkeyScript))).sortBy(_._1).map(_._2), - fundingParams.lockTime - ) - val fundingTxId = fundingTx.txid - TxComplete(fundingParams.channelId, - NonceGenerator.verificationNonce(fundingTxId, localFundingKey, fundingParams.remoteFundingPubKey, purpose.localCommitIndex).publicNonce, - NonceGenerator.verificationNonce(fundingTxId, localFundingKey, fundingParams.remoteFundingPubKey, purpose.localCommitIndex + 1).publicNonce, - localNonce_opt.map(_.publicNonce) + lockTime = fundingParams.lockTime + ).txid + TxComplete( + channelId = fundingParams.channelId, + commitNonce = NonceGenerator.verificationNonce(fundingTxId, localFundingKey, fundingParams.remoteFundingPubKey, purpose.localCommitIndex).publicNonce, + nextCommitNonce = NonceGenerator.verificationNonce(fundingTxId, localFundingKey, fundingParams.remoteFundingPubKey, purpose.localCommitIndex + 1).publicNonce, + fundingNonce_opt = localFundingNonce_opt.map(_.publicNonce), ) - case _: AnchorOutputsCommitmentFormat | DefaultCommitmentFormat => - TxComplete(fundingParams.channelId) } replyTo ! SendMessage(sessionId, txComplete) val next = session.copy(txCompleteSent = Some(txComplete)) @@ -566,7 +581,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon } } - private def receiveInput(session: InteractiveTxSession, addInput: TxAddInput): Either[ChannelException, InteractiveTxSession] = { + private def receiveInput(session: InteractiveTxSession, addInput: TxAddInput): Either[ChannelException, IncomingInput] = { if (session.inputsReceivedCount + 1 >= MAX_INPUTS_OUTPUTS_RECEIVED) { return Left(TooManyInteractiveTxRounds(fundingParams.channelId)) } @@ -597,11 +612,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon if (input.sequence > 0xfffffffdL) { return Left(NonReplaceableInput(fundingParams.channelId, addInput.serialId, input.outPoint.txid, input.outPoint.index, addInput.sequence)) } - Right(session.copy( - remoteInputs = session.remoteInputs :+ input, - inputsReceivedCount = session.inputsReceivedCount + 1, - txCompleteReceived = None - )) + Right(input) } private def receiveOutput(session: InteractiveTxSession, addOutput: TxAddOutput): Either[ChannelException, IncomingOutput] = { @@ -633,7 +644,12 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon case Left(f) => replyTo ! RemoteFailure(f) unlockAndStop(session) - case Right(next) => + case Right(input) => + val next = session.copy( + remoteInputs = session.remoteInputs :+ input, + inputsReceivedCount = session.inputsReceivedCount + 1, + txCompleteReceived = None, + ) send(next) } case addOutput: TxAddOutput => @@ -704,7 +720,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon replyTo ! RemoteFailure(cause) unlockAndStop(session) case Right(completeTx) => - signCommitTx(session, completeTx) + signCommitTx(completeTx, session.txCompleteReceived.flatMap(_.nonces_opt)) } case _: WalletFailure => replyTo ! RemoteFailure(UnconfirmedInteractiveTxInputs(fundingParams.channelId)) @@ -760,7 +776,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon return Left(InvalidCompleteInteractiveTx(fundingParams.channelId)) } - val sharedInput_opt = fundingParams.sharedInput_opt.map(_ => { + val sharedInput_opt = fundingParams.sharedInput_opt.map(sharedInput => { if (fundingParams.remoteContribution >= 0.sat) { // If remote has a positive contribution, we do not check their post-splice reserve level, because they are improving // their situation, even if they stay below the requirement. Note that if local splices-in some funds in the same @@ -777,6 +793,13 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon log.warn("invalid interactive tx: shared input included multiple times") return Left(InvalidCompleteInteractiveTx(fundingParams.channelId)) } + sharedInput.commitmentFormat match { + case _: SegwitV0CommitmentFormat => () + case _: SimpleTaprootChannelCommitmentFormat => + // If we're spending a taproot channel, our peer must provide a nonce for the shared input. + val remoteFundingNonce_opt: Option[IndividualNonce] = session.txCompleteReceived.flatMap(_.nonces_opt).flatMap(_.fundingNonce_opt) + if (remoteFundingNonce_opt.isEmpty) return Left(MissingFundingNonce(fundingParams.channelId, sharedInput.info.outPoint.txid)) + } sharedInputs.headOption match { case Some(input) => input case None => @@ -792,6 +815,14 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon return Left(InvalidCompleteInteractiveTx(fundingParams.channelId)) } + // If we're using taproot, our peer must provide commit nonces for the funding transaction. + fundingParams.commitmentFormat match { + case _: SegwitV0CommitmentFormat => () + case _: SimpleTaprootChannelCommitmentFormat => + val remoteCommitNonces_opt = session.txCompleteReceived.flatMap(_.nonces_opt) + if (remoteCommitNonces_opt.isEmpty) return Left(MissingCommitNonce(fundingParams.channelId, tx.txid, purpose.remoteCommitIndex)) + } + // The transaction isn't signed yet, and segwit witnesses can be arbitrarily low (e.g. when using an OP_1 script), // so we use empty witnesses to provide a lower bound on the transaction weight. if (tx.weight() > Transactions.MAX_STANDARD_TX_WEIGHT) { @@ -857,7 +888,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon Right(sharedTx) } - private def signCommitTx(session: InteractiveTxSession, completeTx: SharedTransaction): Behavior[Command] = { + private def signCommitTx(completeTx: SharedTransaction, remoteNonces_opt: Option[TxCompleteTlv.Nonces]): Behavior[Command] = { val fundingTx = completeTx.buildUnsignedTx() val fundingOutputIndex = fundingTx.txOut.indexWhere(_.publicKeyScript == fundingPubkeyScript) val liquidityFee = fundingParams.liquidityFees(liquidityPurchase_opt) @@ -881,41 +912,34 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon case Right((localSpec, localCommitTx, remoteSpec, remoteCommitTx, sortedHtlcTxs)) => require(fundingTx.txOut(fundingOutputIndex).publicKeyScript == localCommitTx.input.txOut.publicKeyScript, "pubkey script mismatch!") val localSigOfRemoteTx = fundingParams.commitmentFormat match { - case _: SimpleTaprootChannelCommitmentFormat => IndividualSignature(ByteVector64.Zeroes) - case _: SegwitV0CommitmentFormat => remoteCommitTx.sign(localFundingKey, fundingParams.remoteFundingPubKey) - } - - def makeTlvs(): Either[ChannelException, TlvStream[CommitSigTlv]] = fundingParams.commitmentFormat match { + case _: SegwitV0CommitmentFormat => Right(remoteCommitTx.sign(localFundingKey, fundingParams.remoteFundingPubKey)) case _: SimpleTaprootChannelCommitmentFormat => - val localNonce = NonceGenerator.signingNonce(localFundingKey.publicKey, fundingParams.remoteFundingPubKey, fundingTx.txid) - val remoteNonce = session.txCompleteReceived.flatMap(_.nonces_opt).map(_.commitNonce) match { - case Some(n) => n - case None => return Left(MissingCommitNonce(channelParams.channelId, fundingTx.txid, purpose.remoteCommitIndex)) - } - val psig = remoteCommitTx.partialSign(localFundingKey, fundingParams.remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteNonce)) match { - case Left(_) => return Left(InvalidCommitNonce(channelParams.channelId, fundingTx.txid, purpose.remoteCommitIndex)) - case Right(psig) => psig + remoteNonces_opt match { + case Some(remoteNonces) => + val localNonce = NonceGenerator.signingNonce(localFundingKey.publicKey, fundingParams.remoteFundingPubKey, fundingTx.txid) + remoteCommitTx.partialSign(localFundingKey, fundingParams.remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteNonces.commitNonce)) match { + case Left(_) => Left(InvalidCommitNonce(channelParams.channelId, fundingTx.txid, purpose.remoteCommitIndex)) + case Right(localSig) => Right(localSig) + } + case None => Left(MissingCommitNonce(fundingParams.channelId, fundingTx.txid, purpose.remoteCommitIndex)) } - Right(TlvStream(CommitSigTlv.PartialSignatureWithNonceTlv(psig))) - case _: SegwitV0CommitmentFormat => Right(TlvStream.empty[CommitSigTlv]) } - - makeTlvs() match { + localSigOfRemoteTx match { case Left(cause) => replyTo ! RemoteFailure(cause) unlockAndStop(completeTx) - case Right(tlvs) => + case Right(localSigOfRemoteTx) => val htlcSignatures = sortedHtlcTxs.map(_.localSig(remoteCommitmentKeys)).toList - val localCommitSig = CommitSig(fundingParams.channelId, localSigOfRemoteTx, htlcSignatures, tlvs) + val localCommitSig = CommitSig(fundingParams.channelId, localSigOfRemoteTx, htlcSignatures, batchSize = 1) val localCommit = UnsignedLocalCommit(purpose.localCommitIndex, localSpec, localCommitTx.tx.txid) val remoteCommit = RemoteCommit(purpose.remoteCommitIndex, remoteSpec, remoteCommitTx.tx.txid, purpose.remotePerCommitmentPoint) - signFundingTx(session, completeTx, localCommitSig, localCommit, remoteCommit) + signFundingTx(completeTx, remoteNonces_opt, localCommitSig, localCommit, remoteCommit) } } } - private def signFundingTx(session: InteractiveTxSession, completeTx: SharedTransaction, commitSig: CommitSig, localCommit: UnsignedLocalCommit, remoteCommit: RemoteCommit): Behavior[Command] = { - signTx(session, completeTx) + private def signFundingTx(completeTx: SharedTransaction, remoteNonces_opt: Option[TxCompleteTlv.Nonces], commitSig: CommitSig, localCommit: UnsignedLocalCommit, remoteCommit: RemoteCommit): Behavior[Command] = { + signTx(completeTx, remoteNonces_opt.flatMap(_.fundingNonce_opt)) Behaviors.receiveMessagePartial { case SignTransactionResult(signedTx) => log.info(s"interactive-tx txid=${signedTx.txId} partially signed with {} local inputs, {} remote inputs, {} local outputs and {} remote outputs", signedTx.tx.localInputs.length, signedTx.tx.remoteInputs.length, signedTx.tx.localOutputs.length, signedTx.tx.remoteOutputs.length) @@ -952,10 +976,8 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon remoteCommit, liquidityPurchase_opt.map(_.basicInfo(isBuyer = fundingParams.isInitiator)) ) - // the last nonce they've sent becomes their "next remote nonce" - val fundingTxId = validateTx(session).map(_.buildUnsignedTx().txid).getOrElse(throw new RuntimeException("invalid signing session")) - val theirNextCommitNonce = session.txCompleteReceived.flatMap(_.nonces_opt).map(n => fundingTxId -> n.nextCommitNonce) - replyTo ! Succeeded(signingSession, commitSig, liquidityPurchase_opt, theirNextCommitNonce) + val nextRemoteCommitNonce_opt = remoteNonces_opt.map(n => signedTx.txId -> n.nextCommitNonce) + replyTo ! Succeeded(signingSession, commitSig, liquidityPurchase_opt, nextRemoteCommitNonce_opt) Behaviors.stopped case WalletFailure(t) => log.error("could not sign funding transaction: ", t) @@ -970,73 +992,56 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon } } - private def signTx(session: InteractiveTxSession, unsignedTx: SharedTransaction): Unit = { + private def signTx(unsignedTx: SharedTransaction, remoteFundingNonce_opt: Option[IndividualNonce]): Unit = { import fr.acinq.bitcoin.scalacompat.KotlinUtils._ val tx = unsignedTx.buildUnsignedTx() - val sharedSig_opt = fundingParams.sharedInput_opt.map { i => - i.commitmentFormat match { - case _: SimpleTaprootChannelCommitmentFormat => - // there should be a single shared input - val localNonce = localNonce_opt.get - val fundingKey = channelKeys.fundingKey(i.fundingTxIndex) - val inputIndex = tx.txIn.indexWhere(_.outPoint == i.info.outPoint) - // there should be one remote nonce for each shared input ordered by serial id - val remoteNonce = session.txCompleteReceived.flatMap(_.nonces_opt).flatMap(_.fundingNonce_opt) match { - case Some(n) => n - case None => - context.self ! WalletFailure(MissingFundingNonce(channelParams.channelId, tx.txid)) - return - } - val psig = Musig2.signTaprootInput(fundingKey, tx, inputIndex, unsignedTx.spentOutputs, Scripts.sort(Seq(fundingKey.publicKey, i.remoteFundingPubkey)), localNonce.secretNonce, Seq(localNonce.publicNonce, remoteNonce), None) match { - case Left(_) => - context.self ! WalletFailure(InvalidFundingNonce(channelParams.channelId, tx.txid)) - return - case Right(psig) => psig - } - PartialSignatureWithNonce(psig, localNonce.publicNonce) - case _: AnchorOutputsCommitmentFormat | DefaultCommitmentFormat => i.sign(channelKeys, tx, unsignedTx.inputDetails) - } + val sharedSig_opt = fundingParams.sharedInput_opt match { + case Some(i) => i.sign(fundingParams.channelId, channelKeys, tx, localFundingNonce_opt, remoteFundingNonce_opt, unsignedTx.inputDetails).map(sig => Some(sig)) + case None => Right(None) } - if (unsignedTx.localInputs.isEmpty) { - context.self ! SignTransactionResult(PartiallySignedSharedTransaction(unsignedTx, TxSignatures(fundingParams.channelId, tx, Nil, sharedSig_opt))) - } else { - // We track our wallet inputs and outputs, so we can verify them when we sign the transaction: if Eclair is managing bitcoin core wallet keys, it will - // only sign our wallet inputs, and check that it can re-compute private keys for our wallet outputs. - val ourWalletInputs = unsignedTx.localInputs.map(i => tx.txIn.indexWhere(_.outPoint == i.outPoint)) - val ourWalletOutputs = unsignedTx.localOutputs.flatMap { - case Output.Local.Change(_, amount, pubkeyScript) => Some(tx.txOut.indexWhere(output => output.amount == amount && output.publicKeyScript == pubkeyScript)) - // Non-change outputs may go to an external address (typically during a splice-out). - // Here we only keep outputs which are ours i.e explicitly go back into our wallet. - // We trust that non-change outputs are valid: this only works if the entry point for creating such outputs is trusted (for example, a secure API call). - case _: Output.Local.NonChange => None - } - // If this is a splice, the PSBT we create must contain the shared input, because if we use taproot wallet inputs - // we need information about *all* of the transaction's inputs, not just the one we're signing. - val psbt = unsignedTx.sharedInput_opt.flatMap { - si => new Psbt(tx).updateWitnessInput(si.outPoint, si.txOut, null, null, null, java.util.Map.of(), null, null, java.util.Map.of()).toOption - }.getOrElse(new Psbt(tx)) - context.pipeToSelf(wallet.signPsbt(psbt, ourWalletInputs, ourWalletOutputs).map { - response => - val localOutpoints = unsignedTx.localInputs.map(_.outPoint).toSet - val partiallySignedTx = response.partiallySignedTx - // Partially signed PSBT must include spent amounts for all inputs that were signed, and we can "trust" these amounts because they are included - // in the hash that we signed (see BIP143). If our bitcoin node lied about them, then our signatures are invalid. - val actualLocalAmountIn = ourWalletInputs.map(i => kmp2scala(response.psbt.getInput(i).getWitnessUtxo.amount)).sum - val expectedLocalAmountIn = unsignedTx.localInputs.map(i => i.txOut.amount).sum - require(actualLocalAmountIn == expectedLocalAmountIn, s"local spent amount $actualLocalAmountIn does not match what we expect ($expectedLocalAmountIn): bitcoin core may be malicious") - val actualLocalAmountOut = ourWalletOutputs.map(i => partiallySignedTx.txOut(i).amount).sum - val expectedLocalAmountOut = unsignedTx.localOutputs.map { - case c: Output.Local.Change => c.amount - case _: Output.Local.NonChange => 0.sat - }.sum - require(actualLocalAmountOut == expectedLocalAmountOut, s"local output amount $actualLocalAmountOut does not match what we expect ($expectedLocalAmountOut): bitcoin core may be malicious") - val sigs = partiallySignedTx.txIn.filter(txIn => localOutpoints.contains(txIn.outPoint)).map(_.witness) - PartiallySignedSharedTransaction(unsignedTx, TxSignatures(fundingParams.channelId, partiallySignedTx, sigs, sharedSig_opt)) - }) { - case Failure(t) => WalletFailure(t) - case Success(signedTx) => SignTransactionResult(signedTx) - } + sharedSig_opt match { + case Left(f) => + context.self ! WalletFailure(f) + case Right(sharedSig_opt) if unsignedTx.localInputs.isEmpty => + context.self ! SignTransactionResult(PartiallySignedSharedTransaction(unsignedTx, TxSignatures(fundingParams.channelId, tx, Nil, sharedSig_opt))) + case Right(sharedSig_opt) => + // We track our wallet inputs and outputs, so we can verify them when we sign the transaction: if Eclair is managing bitcoin core wallet keys, it will + // only sign our wallet inputs, and check that it can re-compute private keys for our wallet outputs. + val ourWalletInputs = unsignedTx.localInputs.map(i => tx.txIn.indexWhere(_.outPoint == i.outPoint)) + val ourWalletOutputs = unsignedTx.localOutputs.flatMap { + case Output.Local.Change(_, amount, pubkeyScript) => Some(tx.txOut.indexWhere(output => output.amount == amount && output.publicKeyScript == pubkeyScript)) + // Non-change outputs may go to an external address (typically during a splice-out). + // Here we only keep outputs which are ours i.e explicitly go back into our wallet. + // We trust that non-change outputs are valid: this only works if the entry point for creating such outputs is trusted (for example, a secure API call). + case _: Output.Local.NonChange => None + } + // If this is a splice, the PSBT we create must contain the shared input, because if we use taproot wallet inputs + // we need information about *all* of the transaction's inputs, not just the one we're signing. + val psbt = unsignedTx.sharedInput_opt.flatMap { + si => new Psbt(tx).updateWitnessInput(si.outPoint, si.txOut, null, null, null, java.util.Map.of(), null, null, java.util.Map.of()).toOption + }.getOrElse(new Psbt(tx)) + context.pipeToSelf(wallet.signPsbt(psbt, ourWalletInputs, ourWalletOutputs).map { + response => + val localOutpoints = unsignedTx.localInputs.map(_.outPoint).toSet + val partiallySignedTx = response.partiallySignedTx + // Partially signed PSBT must include spent amounts for all inputs that were signed, and we can "trust" these amounts because they are included + // in the hash that we signed (see BIP143). If our bitcoin node lied about them, then our signatures are invalid. + val actualLocalAmountIn = ourWalletInputs.map(i => kmp2scala(response.psbt.getInput(i).getWitnessUtxo.amount)).sum + val expectedLocalAmountIn = unsignedTx.localInputs.map(i => i.txOut.amount).sum + require(actualLocalAmountIn == expectedLocalAmountIn, s"local spent amount $actualLocalAmountIn does not match what we expect ($expectedLocalAmountIn): bitcoin core may be malicious") + val actualLocalAmountOut = ourWalletOutputs.map(i => partiallySignedTx.txOut(i).amount).sum + val expectedLocalAmountOut = unsignedTx.localOutputs.map { + case c: Output.Local.Change => c.amount + case _: Output.Local.NonChange => 0.sat + }.sum + require(actualLocalAmountOut == expectedLocalAmountOut, s"local output amount $actualLocalAmountOut does not match what we expect ($expectedLocalAmountOut): bitcoin core may be malicious") + val sigs = partiallySignedTx.txIn.filter(txIn => localOutpoints.contains(txIn.outPoint)).map(_.witness) + PartiallySignedSharedTransaction(unsignedTx, TxSignatures(fundingParams.channelId, partiallySignedTx, sigs, sharedSig_opt)) + }) { + case Failure(t) => WalletFailure(t) + case Success(signedTx) => SignTransactionResult(signedTx) + } } } @@ -1122,40 +1127,24 @@ object InteractiveTxSigningSession { return Left(InvalidFundingSignature(fundingParams.channelId, Some(partiallySignedTx.txId))) } val sharedSigs_opt = fundingParams.sharedInput_opt.map(sharedInput => { - sharedInput.commitmentFormat match { - case _: SegwitV0CommitmentFormat => (partiallySignedTx.localSigs.previousFundingTxSig_opt, remoteSigs.previousFundingTxSig_opt) match { - case (Some(localSig), Some(remoteSig)) => - val localFundingPubkey = channelKeys.fundingKey(sharedInput.fundingTxIndex).publicKey - Scripts.witness2of2(localSig, remoteSig, localFundingPubkey, sharedInput.remoteFundingPubkey) - case _ => - log.info("invalid tx_signatures: missing shared input signatures") - return Left(InvalidFundingSignature(fundingParams.channelId, Some(partiallySignedTx.txId))) - } + val localFundingPubkey = channelKeys.fundingKey(sharedInput.fundingTxIndex).publicKey + val spliceTx = Transactions.SpliceTx(sharedInput.info, partiallySignedTx.tx.buildUnsignedTx()) + val signedTx_opt = sharedInput.commitmentFormat match { + case _: SegwitV0CommitmentFormat => + (partiallySignedTx.localSigs.previousFundingTxSig_opt, remoteSigs.previousFundingTxSig_opt) match { + case (Some(localSig), Some(remoteSig)) => Right(spliceTx.aggregateSigs(localFundingPubkey, sharedInput.remoteFundingPubkey, IndividualSignature(localSig), IndividualSignature(remoteSig))) + case _ => Left(InvalidFundingSignature(fundingParams.channelId, Some(partiallySignedTx.txId))) + } case _: SimpleTaprootChannelCommitmentFormat => (partiallySignedTx.localSigs.previousFundingTxPartialSig_opt, remoteSigs.previousFundingTxPartialSig_opt) match { - case (Some(localPartialSig), Some(remotePartialSig)) => - val localFundingPubkey = channelKeys.fundingKey(sharedInput.fundingTxIndex).publicKey - val unsignedTx = partiallySignedTx.tx.buildUnsignedTx() - val inputIndex = unsignedTx.txIn.indexWhere(_.outPoint == sharedInput.info.outPoint) - val aggSig = Musig2.aggregateTaprootSignatures( - Seq(localPartialSig.partialSig, remotePartialSig.partialSig), - unsignedTx, - inputIndex, - partiallySignedTx.tx.spentOutputs, - Scripts.sort(Seq(localFundingPubkey, sharedInput.remoteFundingPubkey)), - Seq(localPartialSig.nonce, remotePartialSig.nonce), - None) - aggSig match { - case Right(sig) => Script.witnessKeyPathPay2tr(sig) - case Left(error) => - log.warning(s"adding remote sigs for ${unsignedTx.txid} local partial sig ${localPartialSig.partialSig} is using nonce ${localPartialSig.nonce} remote partial sig ${remotePartialSig.partialSig} is using nonce ${remotePartialSig.nonce} local funding key = $localFundingPubkey remote funding key = ${sharedInput.remoteFundingPubkey} spent outputs = ${partiallySignedTx.tx.spentOutputs} failed with $error") - return Left(InvalidFundingSignature(fundingParams.channelId, Some(partiallySignedTx.txId))) - } - case _ => - log.info("invalid tx_signatures: missing shared input partial signatures") - return Left(InvalidFundingSignature(fundingParams.channelId, Some(partiallySignedTx.txId))) + case (Some(localSig), Some(remoteSig)) => spliceTx.aggregateSigs(localFundingPubkey, sharedInput.remoteFundingPubkey, localSig, remoteSig, partiallySignedTx.tx.inputDetails) + case _ => Left(InvalidFundingSignature(fundingParams.channelId, Some(partiallySignedTx.txId))) } } + signedTx_opt match { + case Left(_) => return Left(InvalidFundingSignature(fundingParams.channelId, Some(partiallySignedTx.txId))) + case Right(signedTx) => signedTx.txIn(spliceTx.inputIndex).witness + } }) val txWithSigs = FullySignedSharedTransaction(partiallySignedTx.tx, partiallySignedTx.localSigs, remoteSigs, sharedSigs_opt) if (remoteSigs.txId != txWithSigs.signedTx.txid) { From 7c764dd70aaa8b06fd5d19feda80cf9aac3faa50 Mon Sep 17 00:00:00 2001 From: t-bast Date: Tue, 5 Aug 2025 17:21:20 +0200 Subject: [PATCH 14/18] Add interactive-tx taproot tests The existing code duplicated the interactive-tx test suite for taproot. This has many drawbacks: - it runs a lot of tests twice for exactly the same codepaths - it spins up another `bitcoind` instance, which is expensive - it doesn't test the taproot-related stuff in enough depth - it doesn't test taproot with `eclair-signer = true` We improve this by reverting these test changes and instead: - changing some of the tests to use taproot - duplicating a couple of tests that have different behavior in taproot and non-taproot cases - adding a test for the commitment upgrade during a splice --- .../channel/InteractiveTxBuilderSpec.scala | 350 ++++++++++++------ 1 file changed, 241 insertions(+), 109 deletions(-) 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 a0a9f8e0bf..82ffd78545 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 @@ -21,10 +21,9 @@ import akka.actor.typed.scaladsl.adapter.{ClassicActorSystemOps, actorRefAdapter import akka.pattern.pipe import akka.testkit.TestProbe import com.softwaremill.quicklens.{ModifyPimp, QuicklensAt} -import fr.acinq.bitcoin.crypto.musig2.IndividualNonce import fr.acinq.bitcoin.psbt.Psbt import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey -import fr.acinq.bitcoin.scalacompat.{Block, ByteVector32, ByteVector64, Musig2, OP_1, OutPoint, Satoshi, SatoshiLong, Script, ScriptWitness, Transaction, TxHash, TxId, TxIn, TxOut, addressToPublicKeyScript} +import fr.acinq.bitcoin.scalacompat.{Block, ByteVector32, ByteVector64, OP_1, OutPoint, Satoshi, SatoshiLong, Script, ScriptWitness, Transaction, TxHash, TxId, TxIn, TxOut, addressToPublicKeyScript} import fr.acinq.eclair.TestUtils.randomTxId import fr.acinq.eclair.blockchain.OnChainWallet.{FundTransactionResponse, ProcessPsbtResponse} import fr.acinq.eclair.blockchain.bitcoind.BitcoindService @@ -37,10 +36,10 @@ import fr.acinq.eclair.channel.fund.InteractiveTxBuilder._ import fr.acinq.eclair.channel.fund.{InteractiveTxBuilder, InteractiveTxSigningSession} import fr.acinq.eclair.crypto.keymanager.ChannelKeys import fr.acinq.eclair.io.OpenChannelInterceptor.makeChannelParams -import fr.acinq.eclair.transactions.Transactions.{InputInfo, SimpleTaprootChannelCommitmentFormat} +import fr.acinq.eclair.transactions.Transactions.{CommitmentFormat, InputInfo, PhoenixSimpleTaprootChannelCommitmentFormat, UnsafeLegacyAnchorOutputsCommitmentFormat} import fr.acinq.eclair.transactions.{Scripts, Transactions} import fr.acinq.eclair.wire.protocol._ -import fr.acinq.eclair.{Feature, FeatureSupport, Features, InitFeature, MilliSatoshiLong, NodeParams, TestConstants, TestKitBaseClass, ToMilliSatoshiConversion, UInt64, randomBytes32, randomKey} +import fr.acinq.eclair.{Feature, FeatureSupport, Features, MilliSatoshiLong, NodeParams, TestConstants, TestKitBaseClass, ToMilliSatoshiConversion, UInt64, randomBytes32, randomKey} import org.scalatest.BeforeAndAfterAll import org.scalatest.funsuite.AnyFunSuiteLike import scodec.bits.{ByteVector, HexStringSyntax} @@ -52,9 +51,6 @@ import scala.reflect.ClassTag class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike with BitcoindService with BeforeAndAfterAll { - val channelType: SupportedChannelType = ChannelTypes.AnchorOutputsZeroFeeHtlcTx() - val txCompleteNonces: collection.mutable.HashSet[IndividualNonce] = collection.mutable.HashSet.empty[IndividualNonce] - override def beforeAll(): Unit = { startBitcoind() waitForBitcoindReady() @@ -98,8 +94,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit fundingParamsB: InteractiveTxParams, nodeParamsB: NodeParams, channelParamsB: ChannelParams, - commitParamsB: CommitParams, - channelFeatures: ChannelFeatures) { + commitParamsB: CommitParams) { val channelId: ByteVector32 = fundingParamsA.channelId val commitFeerate: FeeratePerKw = TestConstants.anchorOutputsFeeratePerKw val channelKeysA: ChannelKeys = nodeParamsA.channelKeyManager.channelKeys(channelParamsA.channelConfig, channelParamsA.localParams.fundingKeyPath) @@ -107,7 +102,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit private val firstPerCommitmentPointA = channelKeysA.commitmentPoint(0) private val firstPerCommitmentPointB = channelKeysB.commitmentPoint(0) - val fundingPubkeyScript: ByteVector = Transactions.makeFundingScript(fundingParamsB.remoteFundingPubKey, fundingParamsA.remoteFundingPubKey, channelType.commitmentFormat).pubkeyScript + val fundingPubkeyScript: ByteVector = Transactions.makeFundingScript(fundingParamsB.remoteFundingPubKey, fundingParamsA.remoteFundingPubKey, fundingParamsA.commitmentFormat).pubkeyScript def sharedInputs(commitmentA: Commitment, commitmentB: Commitment): (SharedFundingInput, SharedFundingInput) = { val sharedInputA = SharedFundingInput(channelKeysA, commitmentA) @@ -124,11 +119,12 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit SharedFundingInput(inputInfo, fundingTxIndex, fundingParamsA.remoteFundingPubKey, fundingParamsA.commitmentFormat) } - def createSpliceFixtureParams(fundingTxIndex: Long, fundingAmountA: Satoshi, fundingAmountB: Satoshi, targetFeerate: FeeratePerKw, dustLimit: Satoshi, lockTime: Long, sharedInputA: SharedFundingInput, sharedInputB: SharedFundingInput, spliceOutputsA: List[TxOut] = Nil, spliceOutputsB: List[TxOut] = Nil, requireConfirmedInputs: RequireConfirmedInputs = RequireConfirmedInputs(forLocal = false, forRemote = false)): FixtureParams = { + def createSpliceFixtureParams(fundingTxIndex: Long, fundingAmountA: Satoshi, fundingAmountB: Satoshi, targetFeerate: FeeratePerKw, dustLimit: Satoshi, lockTime: Long, sharedInputA: SharedFundingInput, sharedInputB: SharedFundingInput, nextCommitmentFormat_opt: Option[CommitmentFormat] = None, spliceOutputsA: List[TxOut] = Nil, spliceOutputsB: List[TxOut] = Nil, requireConfirmedInputs: RequireConfirmedInputs = RequireConfirmedInputs(forLocal = false, forRemote = false)): FixtureParams = { val fundingPubKeyA = channelKeysA.fundingKey(fundingTxIndex).publicKey val fundingPubKeyB = channelKeysB.fundingKey(fundingTxIndex).publicKey - val fundingParamsA1 = InteractiveTxParams(channelId, isInitiator = true, fundingAmountA, fundingAmountB, Some(sharedInputA), fundingPubKeyB, spliceOutputsA, fundingParamsA.commitmentFormat, lockTime, dustLimit, targetFeerate, requireConfirmedInputs) - val fundingParamsB1 = InteractiveTxParams(channelId, isInitiator = false, fundingAmountB, fundingAmountA, Some(sharedInputB), fundingPubKeyA, spliceOutputsB, fundingParamsB.commitmentFormat, lockTime, dustLimit, targetFeerate, requireConfirmedInputs) + val nextCommitmentFormat = nextCommitmentFormat_opt.getOrElse(fundingParamsA.commitmentFormat) + val fundingParamsA1 = InteractiveTxParams(channelId, isInitiator = true, fundingAmountA, fundingAmountB, Some(sharedInputA), fundingPubKeyB, spliceOutputsA, nextCommitmentFormat, lockTime, dustLimit, targetFeerate, requireConfirmedInputs) + val fundingParamsB1 = InteractiveTxParams(channelId, isInitiator = false, fundingAmountB, fundingAmountA, Some(sharedInputB), fundingPubKeyA, spliceOutputsB, nextCommitmentFormat, lockTime, dustLimit, targetFeerate, requireConfirmedInputs) copy(fundingParamsA = fundingParamsA1, fundingParamsB = fundingParamsB1) } @@ -221,9 +217,8 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit } } - private def createFixtureParams(fundingAmountA: Satoshi, fundingAmountB: Satoshi, targetFeerate: FeeratePerKw, dustLimit: Satoshi, lockTime: Long, requireConfirmedInputs: RequireConfirmedInputs = RequireConfirmedInputs(forLocal = false, forRemote = false), nonInitiatorPaysCommitTxFees: Boolean = false): FixtureParams = { - val channelFeatures = ChannelFeatures(channelType, Features[InitFeature](Features.DualFunding -> FeatureSupport.Optional), Features[InitFeature](Features.DualFunding -> FeatureSupport.Optional), announceChannel = true) - val Seq(nodeParamsA, nodeParamsB) = Seq(TestConstants.Alice.nodeParams, TestConstants.Bob.nodeParams).map(_.copy(features = Features(channelFeatures.features.map(f => f -> FeatureSupport.Optional).toMap[Feature, FeatureSupport]))) + private def createFixtureParams(channelType: SupportedChannelType, fundingAmountA: Satoshi, fundingAmountB: Satoshi, targetFeerate: FeeratePerKw, dustLimit: Satoshi, lockTime: Long, requireConfirmedInputs: RequireConfirmedInputs = RequireConfirmedInputs(forLocal = false, forRemote = false), nonInitiatorPaysCommitTxFees: Boolean = false): FixtureParams = { + val Seq(nodeParamsA, nodeParamsB) = Seq(TestConstants.Alice.nodeParams, TestConstants.Bob.nodeParams).map(_.copy(features = Features(channelType.features.map(f => f -> FeatureSupport.Optional).toMap[Feature, FeatureSupport]))) val localChannelParamsA = makeChannelParams(nodeParamsA, nodeParamsA.features.initFeatures(), None, None, isChannelOpener = true, paysCommitTxFees = !nonInitiatorPaysCommitTxFees, dualFunded = true, fundingAmountA) val commitParamsA = CommitParams(nodeParamsA.channelConf.dustLimit, nodeParamsA.channelConf.htlcMinimum, nodeParamsA.channelConf.maxHtlcValueInFlight(fundingAmountA + fundingAmountB, unlimited = false), nodeParamsA.channelConf.maxAcceptedHtlcs, nodeParamsB.channelConf.toRemoteDelay) val localChannelParamsB = makeChannelParams(nodeParamsB, nodeParamsB.features.initFeatures(), None, None, isChannelOpener = false, paysCommitTxFees = nonInitiatorPaysCommitTxFees, dualFunded = true, fundingAmountB) @@ -249,10 +244,10 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit val fundingPubKeyB = channelKeysB.fundingKey(fundingTxIndex = 0).publicKey val fundingParamsA = InteractiveTxParams(channelId, isInitiator = true, fundingAmountA, fundingAmountB, None, fundingPubKeyB, Nil, channelType.commitmentFormat, lockTime, dustLimit, targetFeerate, requireConfirmedInputs) val fundingParamsB = InteractiveTxParams(channelId, isInitiator = false, fundingAmountB, fundingAmountA, None, fundingPubKeyA, Nil, channelType.commitmentFormat, lockTime, dustLimit, targetFeerate, requireConfirmedInputs) - val channelParamsA = ChannelParams(channelId, ChannelConfig.standard, channelFeatures, localChannelParamsA, remoteChannelParamsB, ChannelFlags(announceChannel = true)) - val channelParamsB = ChannelParams(channelId, ChannelConfig.standard, channelFeatures, localChannelParamsB, remoteChannelParamsA, ChannelFlags(announceChannel = true)) + val channelParamsA = ChannelParams(channelId, ChannelConfig.standard, ChannelFeatures(Features.DualFunding), localChannelParamsA, remoteChannelParamsB, ChannelFlags(announceChannel = true)) + val channelParamsB = ChannelParams(channelId, ChannelConfig.standard, ChannelFeatures(Features.DualFunding), localChannelParamsB, remoteChannelParamsA, ChannelFlags(announceChannel = true)) - FixtureParams(fundingParamsA, nodeParamsA, channelParamsA, commitParamsA, fundingParamsB, nodeParamsB, channelParamsB, commitParamsB, channelFeatures) + FixtureParams(fundingParamsA, nodeParamsA, channelParamsA, commitParamsA, fundingParamsB, nodeParamsB, channelParamsB, commitParamsB) } case class Fixture(alice: ActorRef[InteractiveTxBuilder.Command], @@ -282,25 +277,14 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit val c = t.runtimeClass.asInstanceOf[Class[T]] assert(c.isInstance(msg), s"expected $c, found ${msg.getClass} ($msg)") msg match { - case msg: InteractiveTxConstructionMessage => - msg match { - case tc: TxComplete => - tc.nonces_opt.foreach(nonces => { - assert(!txCompleteNonces.contains(nonces.commitNonce), "commit nonce reuse") - assert(!txCompleteNonces.contains(nonces.nextCommitNonce), "next commit nonce reuse") - txCompleteNonces.add(nonces.commitNonce) - txCompleteNonces.add(nonces.nextCommitNonce) - }) - case _ => () - } - r ! ReceiveMessage(msg) + case msg: InteractiveTxConstructionMessage => r ! ReceiveMessage(msg) case msg => fail(s"invalid message sent ($msg)") } msg.asInstanceOf[T] } } - private def withFixture(fundingAmountA: Satoshi, utxosA: Seq[Satoshi], fundingAmountB: Satoshi, utxosB: Seq[Satoshi], targetFeerate: FeeratePerKw, dustLimit: Satoshi, lockTime: Long, requireConfirmedInputs: RequireConfirmedInputs, liquidityPurchase_opt: Option[LiquidityAds.Purchase] = None)(testFun: Fixture => Any): Unit = { + private def withFixture(channelType: SupportedChannelType, fundingAmountA: Satoshi, utxosA: Seq[Satoshi], fundingAmountB: Satoshi, utxosB: Seq[Satoshi], targetFeerate: FeeratePerKw, dustLimit: Satoshi, lockTime: Long, requireConfirmedInputs: RequireConfirmedInputs, liquidityPurchase_opt: Option[LiquidityAds.Purchase] = None)(testFun: Fixture => Any): Unit = { // Initialize wallets with a few confirmed utxos. val probe = TestProbe() val rpcClientA = createWallet(UUID.randomUUID().toString) @@ -311,7 +295,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit utxosB.foreach(amount => addUtxo(walletB, amount, probe)) generateBlocks(1) - val fixtureParams = createFixtureParams(fundingAmountA, fundingAmountB, targetFeerate, dustLimit, lockTime, requireConfirmedInputs, nonInitiatorPaysCommitTxFees = liquidityPurchase_opt.nonEmpty) + val fixtureParams = createFixtureParams(channelType, fundingAmountA, fundingAmountB, targetFeerate, dustLimit, lockTime, requireConfirmedInputs, nonInitiatorPaysCommitTxFees = liquidityPurchase_opt.nonEmpty) val alice = fixtureParams.spawnTxBuilderAlice(walletA, liquidityPurchase_opt = liquidityPurchase_opt) val bob = fixtureParams.spawnTxBuilderBob(walletB, liquidityPurchase_opt = liquidityPurchase_opt) testFun(Fixture(alice, bob, fixtureParams, walletA, rpcClientA, walletB, rpcClientB, TestProbe(), TestProbe())) @@ -323,7 +307,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit val utxosA = Seq(50_000 sat, 35_000 sat, 60_000 sat) val fundingB = 40_000 sat val utxosB = Seq(100_000 sat) - withFixture(fundingA, utxosA, fundingB, utxosB, targetFeerate, 660 sat, 42, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => + withFixture(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), fundingA, utxosA, fundingB, utxosB, targetFeerate, 660 sat, 42, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => import f._ alice ! Start(alice2bob.ref) @@ -400,7 +384,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit val utxosA = Seq(50_000 sat) val fundingB = 50_000 sat val utxosB = Seq(80_000 sat) - withFixture(fundingA, utxosA, fundingB, utxosB, targetFeerate, 660 sat, 0, RequireConfirmedInputs(forLocal = true, forRemote = true)) { f => + withFixture(ChannelTypes.AnchorOutputs(), fundingA, utxosA, fundingB, utxosB, targetFeerate, 660 sat, 0, RequireConfirmedInputs(forLocal = true, forRemote = true)) { f => import f._ alice ! Start(alice2bob.ref) @@ -456,7 +440,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit val utxosA = Seq(150_000 sat) val fundingB = 50_000 sat val utxosB = Seq(200_000 sat) - withFixture(fundingA, utxosA, fundingB, utxosB, targetFeerate, 660 sat, 42, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => + withFixture(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), fundingA, utxosA, fundingB, utxosB, targetFeerate, 660 sat, 42, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => import f._ alice ! Start(alice2bob.ref) @@ -495,7 +479,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit val targetFeerate = FeeratePerKw(2500 sat) val fundingA = 150_000 sat val utxosA = Seq(80_000 sat, 120_000 sat) - withFixture(fundingA, utxosA, 0 sat, Nil, targetFeerate, 660 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => + withFixture(ChannelTypes.SimpleTaprootChannelsStaging(), fundingA, utxosA, 0 sat, Nil, targetFeerate, 660 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => import f._ alice ! Start(alice2bob.ref) @@ -508,17 +492,25 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit // Alice --- tx_add_input --> Bob fwd.forwardAlice2Bob[TxAddInput] // Alice <-- tx_complete --- Bob - fwd.forwardBob2Alice[TxComplete] + val txCompleteB1 = fwd.forwardBob2Alice[TxComplete] // Alice --- tx_add_output --> Bob val outputA1 = fwd.forwardAlice2Bob[TxAddOutput] // Alice <-- tx_complete --- Bob - fwd.forwardBob2Alice[TxComplete] + val txCompleteB2 = fwd.forwardBob2Alice[TxComplete] // Alice --- tx_add_output --> Bob val outputA2 = fwd.forwardAlice2Bob[TxAddOutput] // Alice <-- tx_complete --- Bob - fwd.forwardBob2Alice[TxComplete] + val txCompleteB3 = fwd.forwardBob2Alice[TxComplete] // Alice --- tx_complete --> Bob - fwd.forwardAlice2Bob[TxComplete] + val txCompleteA = fwd.forwardAlice2Bob[TxComplete] + assert(txCompleteA.nonces_opt.nonEmpty) + assert(txCompleteA.nonces_opt.flatMap(_.fundingNonce_opt).isEmpty) + Seq(txCompleteB1, txCompleteB2, txCompleteB3).foreach(txCompleteB => { + assert(txCompleteB.nonces_opt.nonEmpty) + assert(txCompleteB.nonces_opt.flatMap(_.fundingNonce_opt).isEmpty) + }) + // Nonces change every time the shared transaction changes. + assert(Seq(txCompleteB1, txCompleteB2, txCompleteB3).flatMap(_.nonces_opt).flatMap(n => Seq(n.commitNonce, n.nextCommitNonce)).toSet.size == 6) // Alice is responsible for adding the shared output. assert(aliceParams.fundingAmount == fundingA) @@ -527,8 +519,12 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit // Bob sends signatures first as he did not contribute at all. val successA = alice2bob.expectMsgType[Succeeded] + assert(successA.commitSig.sigOrPartialSig.isInstanceOf[PartialSignatureWithNonce]) val successB = bob2alice.expectMsgType[Succeeded] + assert(successB.commitSig.sigOrPartialSig.isInstanceOf[PartialSignatureWithNonce]) val (txA, _, txB, _) = fixtureParams.exchangeSigsBobFirst(bobParams, successA, successB) + assert(successA.nextRemoteCommitNonce_opt.contains((txA.txId, txCompleteB3.nonces_opt.get.nextCommitNonce))) + assert(successB.nextRemoteCommitNonce_opt.contains((txB.txId, txCompleteA.nonces_opt.get.nextCommitNonce))) // The resulting transaction is valid and has the right feerate. assert(txA.txId == txB.txId) assert(txA.signedTx.lockTime == aliceParams.lockTime) @@ -547,7 +543,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit } test("initiator uses unconfirmed inputs") { - withFixture(100_000 sat, Seq(170_000 sat), 0 sat, Nil, FeeratePerKw(2500 sat), 660 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => + withFixture(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), 100_000 sat, Seq(170_000 sat), 0 sat, Nil, FeeratePerKw(2500 sat), 660 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => import f._ // Alice's inputs are all unconfirmed: we spent her only confirmed input to create two unconfirmed outputs. @@ -595,7 +591,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit // When on-the-fly funding is used, the initiator may not contribute to the funding transaction. // It will receive HTLCs later that use the purchased inbound liquidity, and liquidity fees will be deduced from those HTLCs. val purchase = LiquidityAds.Purchase.Standard(fundingB, LiquidityAds.Fees(2500 sat, 7500 sat), LiquidityAds.PaymentDetails.FromFutureHtlc(Nil)) - withFixture(0 sat, Nil, fundingB, utxosB, targetFeerate, 330 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false), Some(purchase)) { f => + withFixture(ChannelTypes.AnchorOutputs(), 0 sat, Nil, fundingB, utxosB, targetFeerate, 330 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false), Some(purchase)) { f => import f._ alice ! Start(alice2bob.ref) @@ -649,7 +645,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit val utxosB = Seq(200_000 sat) // The initiator contributes a small amount, and pays the remaining liquidity fees from its fee credit. val purchase = LiquidityAds.Purchase.WithFeeCredit(fundingB, LiquidityAds.Fees(2500 sat, 7500 sat), 7_500_000 msat, LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(Nil)) - withFixture(fundingA, utxosA, fundingB, utxosB, targetFeerate, 330 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false), Some(purchase)) { f => + withFixture(ChannelTypes.AnchorOutputs(), fundingA, utxosA, fundingB, utxosB, targetFeerate, 330 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false), Some(purchase)) { f => import f._ // Alice has enough fee credit. @@ -700,7 +696,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit val utxosB = Seq(200_000 sat) // The initiator wants to pay the liquidity fees from their fee credit, but they don't have enough of it. val purchase = LiquidityAds.Purchase.WithFeeCredit(fundingB, LiquidityAds.Fees(2500 sat, 7500 sat), 10_000_000 msat, LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc(Nil)) - withFixture(0 sat, Nil, fundingB, utxosB, targetFeerate, 330 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false), Some(purchase)) { f => + withFixture(ChannelTypes.AnchorOutputs(), 0 sat, Nil, fundingB, utxosB, targetFeerate, 330 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false), Some(purchase)) { f => import f._ // Alice doesn't have enough fee credit. @@ -736,7 +732,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit val utxosA = Seq(380_000 sat, 380_000 sat) val fundingB1 = 100_000 sat val utxosB = Seq(350_000 sat, 350_000 sat) - withFixture(fundingA1, utxosA, fundingB1, utxosB, targetFeerate, 660 sat, 0, RequireConfirmedInputs(forLocal = true, forRemote = true)) { f => + withFixture(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), fundingA1, utxosA, fundingB1, utxosB, targetFeerate, 660 sat, 0, RequireConfirmedInputs(forLocal = true, forRemote = true)) { f => import f._ val probe = TestProbe() @@ -829,7 +825,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit val utxosA = Seq(150_000 sat) val fundingB1 = 90_000 sat val utxosB = Seq(130_000 sat) - withFixture(fundingA1, utxosA, fundingB1, utxosB, FeeratePerKw(1000 sat), 660 sat, 0, RequireConfirmedInputs(forLocal = true, forRemote = true)) { f => + withFixture(ChannelTypes.SimpleTaprootChannelsPhoenix(), fundingA1, utxosA, fundingB1, utxosB, FeeratePerKw(1000 sat), 660 sat, 0, RequireConfirmedInputs(forLocal = true, forRemote = true)) { f => import f._ val probe = TestProbe() @@ -852,17 +848,21 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit fwd.forwardAlice2Bob[TxComplete] val successA1 = alice2bob.expectMsgType[Succeeded] + assert(successA1.nextRemoteCommitNonce_opt.nonEmpty) val successB1 = bob2alice.expectMsgType[Succeeded] + assert(successB1.nextRemoteCommitNonce_opt.nonEmpty) val (txA1, commitmentA1, _, commitmentB1) = fixtureParams.exchangeSigsBobFirst(bobParams, successA1, successB1) walletA.publishTransaction(txA1.signedTx).pipeTo(probe.ref) probe.expectMsg(txA1.txId) // Alice and Bob decide to splice funds out of the channel, and deduce on-chain fees from their new channel contribution. - val spliceOutputsA = List(TxOut(50_000 sat, Script.pay2wpkh(randomKey().publicKey))) - val spliceOutputsB = List(TxOut(30_000 sat, Script.pay2wpkh(randomKey().publicKey))) + val spliceOutputsA = List(TxOut(50_000 sat, Script.pay2tr(randomKey().xOnlyPublicKey()))) + val spliceOutputsB = List(TxOut(30_000 sat, Script.pay2tr(randomKey().xOnlyPublicKey()))) val subtractedFundingA = spliceOutputsA.map(_.amount).sum + 1_000.sat val subtractedFundingB = spliceOutputsB.map(_.amount).sum + 500.sat val (sharedInputA, sharedInputB) = fixtureParams.sharedInputs(commitmentA1, commitmentB1) + assert(sharedInputA.commitmentFormat == PhoenixSimpleTaprootChannelCommitmentFormat) + assert(sharedInputB.commitmentFormat == PhoenixSimpleTaprootChannelCommitmentFormat) val spliceFixtureParams = fixtureParams.createSpliceFixtureParams(fundingTxIndex = 1, fundingAmountA = -subtractedFundingA, fundingAmountB = -subtractedFundingB, aliceParams.targetFeerate, aliceParams.dustLimit, aliceParams.lockTime, sharedInputA = sharedInputA, sharedInputB = sharedInputB, spliceOutputsA = spliceOutputsA, spliceOutputsB = spliceOutputsB, requireConfirmedInputs = aliceParams.requireConfirmedInputs) val aliceSplice = fixtureParams.spawnTxBuilderSpliceAlice(spliceFixtureParams.fundingParamsA, commitmentA1, walletA) @@ -890,9 +890,11 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit fwdSplice.forwardAlice2Bob[TxComplete] val successA2 = alice2bob.expectMsgType[Succeeded] - assert(successA2.signingSession.fundingTx.localSigs.previousFundingTxSig_opt.nonEmpty) + assert(successA2.signingSession.fundingTx.localSigs.previousFundingTxSig_opt.isEmpty) + assert(successA2.signingSession.fundingTx.localSigs.previousFundingTxPartialSig_opt.nonEmpty) val successB2 = bob2alice.expectMsgType[Succeeded] - assert(successB2.signingSession.fundingTx.localSigs.previousFundingTxSig_opt.nonEmpty) + assert(successB2.signingSession.fundingTx.localSigs.previousFundingTxSig_opt.isEmpty) + assert(successB2.signingSession.fundingTx.localSigs.previousFundingTxPartialSig_opt.nonEmpty) val (spliceTxA, commitmentA2, spliceTxB, commitmentB2) = fixtureParams.exchangeSigsBobFirst(spliceFixtureParams.fundingParamsB, successA2, successB2) assert(spliceTxA.tx.localFees == 1_000_000.msat) assert(spliceTxB.tx.localFees == 500_000.msat) @@ -917,7 +919,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit val utxosA = Seq(200_000 sat) val fundingB1 = 100_000 sat val utxosB = Seq(150_000 sat) - withFixture(fundingA1, utxosA, fundingB1, utxosB, FeeratePerKw(1000 sat), 660 sat, 0, RequireConfirmedInputs(forLocal = true, forRemote = true)) { f => + withFixture(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), fundingA1, utxosA, fundingB1, utxosB, FeeratePerKw(1000 sat), 660 sat, 0, RequireConfirmedInputs(forLocal = true, forRemote = true)) { f => import f._ val probe = TestProbe() @@ -1013,7 +1015,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit val utxosA = Seq(480_000 sat, 130_000 sat) val fundingB1 = 100_000 sat val utxosB = Seq(340_000 sat, 70_000 sat) - withFixture(fundingA1, utxosA, fundingB1, utxosB, targetFeerate, 660 sat, 0, RequireConfirmedInputs(forLocal = true, forRemote = true)) { f => + withFixture(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), fundingA1, utxosA, fundingB1, utxosB, targetFeerate, 660 sat, 0, RequireConfirmedInputs(forLocal = true, forRemote = true)) { f => import f._ val probe = TestProbe() @@ -1103,9 +1105,116 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit } } + test("initiator upgrades to taproot while splicing-in") { + val targetFeerate = FeeratePerKw(2000 sat) + val fundingA1 = 150_000 sat + val utxosA = Seq(480_000 sat, 130_000 sat) + val fundingB1 = 0 sat + val utxosB = Seq(70_000 sat) + withFixture(ChannelTypes.AnchorOutputs(), fundingA1, utxosA, fundingB1, utxosB, targetFeerate, 750 sat, 0, RequireConfirmedInputs(forLocal = true, forRemote = true)) { f => + import f._ + + val probe = TestProbe() + alice ! Start(alice2bob.ref) + bob ! Start(bob2alice.ref) + + // Alice --- tx_add_input --> Bob + fwd.forwardAlice2Bob[TxAddInput] + // Alice <-- tx_complete --- Bob + fwd.forwardBob2Alice[TxComplete] + // Alice --- tx_add_output --> Bob + fwd.forwardAlice2Bob[TxAddOutput] + // Alice <-- tx_complete --- Bob + fwd.forwardBob2Alice[TxComplete] + // Alice --- tx_add_output --> Bob + fwd.forwardAlice2Bob[TxAddOutput] + // Alice <-- tx_complete --- Bob + fwd.forwardBob2Alice[TxComplete] + // Alice --- tx_complete --> Bob + fwd.forwardAlice2Bob[TxComplete] + + val successA1 = alice2bob.expectMsgType[Succeeded] + val successB1 = bob2alice.expectMsgType[Succeeded] + val (txA1, commitmentA1, _, commitmentB1) = fixtureParams.exchangeSigsBobFirst(bobParams, successA1, successB1) + assert(commitmentA1.commitmentFormat == UnsafeLegacyAnchorOutputsCommitmentFormat) + assert(commitmentB1.commitmentFormat == UnsafeLegacyAnchorOutputsCommitmentFormat) + walletA.publishTransaction(txA1.signedTx).pipeTo(probe.ref) + probe.expectMsg(txA1.txId) + + // Alice decides to splice funds in the channel and upgrade to taproot. + // Bob uses this opportunity to also splice some funds in the channel. + val additionalFundingA2 = 80_000.sat + val additionalFundingB2 = 55_000.sat + val (sharedInputA, sharedInputB) = fixtureParams.sharedInputs(commitmentA1, commitmentB1) + assert(sharedInputA.commitmentFormat == UnsafeLegacyAnchorOutputsCommitmentFormat) + assert(sharedInputB.commitmentFormat == UnsafeLegacyAnchorOutputsCommitmentFormat) + val spliceFixtureParams = fixtureParams.createSpliceFixtureParams(fundingTxIndex = 1, fundingAmountA = additionalFundingA2, fundingAmountB = additionalFundingB2, aliceParams.targetFeerate, aliceParams.dustLimit, aliceParams.lockTime, sharedInputA = sharedInputA, sharedInputB = sharedInputB, nextCommitmentFormat_opt = Some(PhoenixSimpleTaprootChannelCommitmentFormat), requireConfirmedInputs = aliceParams.requireConfirmedInputs) + val aliceSplice = fixtureParams.spawnTxBuilderSpliceAlice(spliceFixtureParams.fundingParamsA, commitmentA1, walletA) + val bobSplice = fixtureParams.spawnTxBuilderSpliceBob(spliceFixtureParams.fundingParamsB, commitmentB1, walletB) + val fwdSplice = TypeCheckedForwarder(aliceSplice, bobSplice, alice2bob, bob2alice) + + aliceSplice ! Start(alice2bob.ref) + bobSplice ! Start(bob2alice.ref) + + // Alice --- tx_add_input --> Bob + fwdSplice.forwardAlice2Bob[TxAddInput] + // Alice <-- tx_add_input --- Bob + fwdSplice.forwardBob2Alice[TxAddInput] + // Alice --- tx_add_input --> Bob + fwdSplice.forwardAlice2Bob[TxAddInput] + // Alice <-- tx_add_output --- Bob + fwdSplice.forwardBob2Alice[TxAddOutput] + // Alice --- tx_add_output --> Bob + fwdSplice.forwardAlice2Bob[TxAddOutput] + // Alice <-- tx_complete --- Bob + fwdSplice.forwardBob2Alice[TxComplete] + // Alice --- tx_add_output --> Bob + fwdSplice.forwardAlice2Bob[TxAddOutput] + // Alice <-- tx_complete --- Bob + val txCompleteB = fwdSplice.forwardBob2Alice[TxComplete] + // Alice --- tx_complete --> Bob + val txCompleteA = fwdSplice.forwardAlice2Bob[TxComplete] + Seq(txCompleteA, txCompleteB).foreach(txComplete => { + assert(txComplete.nonces_opt.nonEmpty) + assert(txComplete.nonces_opt.flatMap(_.fundingNonce_opt).isEmpty) // the previous commitment didn't use taproot + assert(txComplete.nonces_opt.map(n => Seq(n.commitNonce, n.nextCommitNonce)).get.size == 2) + }) + + val successA2 = alice2bob.expectMsgType[Succeeded] + assert(successA2.signingSession.fundingTx.localSigs.previousFundingTxSig_opt.nonEmpty) + assert(successA2.signingSession.fundingTx.localSigs.previousFundingTxPartialSig_opt.isEmpty) + assert(successA2.commitSig.sigOrPartialSig.isInstanceOf[PartialSignatureWithNonce]) + val successB2 = bob2alice.expectMsgType[Succeeded] + assert(successB2.signingSession.fundingTx.localSigs.previousFundingTxSig_opt.nonEmpty) + assert(successB2.signingSession.fundingTx.localSigs.previousFundingTxPartialSig_opt.isEmpty) + assert(successB2.commitSig.sigOrPartialSig.isInstanceOf[PartialSignatureWithNonce]) + val (spliceTxA, commitmentA2, spliceTxB, commitmentB2) = fixtureParams.exchangeSigsBobFirst(spliceFixtureParams.fundingParamsB, successA2, successB2) + assert(successA2.nextRemoteCommitNonce_opt.contains((spliceTxA.txId, txCompleteB.nonces_opt.get.nextCommitNonce))) + assert(successB2.nextRemoteCommitNonce_opt.contains((spliceTxB.txId, txCompleteA.nonces_opt.get.nextCommitNonce))) + assert(spliceTxA.tx.localAmountIn > spliceTxA.tx.remoteAmountIn) + assert(spliceTxA.signedTx.txIn.exists(_.outPoint == commitmentA1.fundingInput)) + assert(0.msat < spliceTxA.tx.localFees) + assert(0.msat < spliceTxA.tx.remoteFees) + assert(spliceTxB.tx.localFees == spliceTxA.tx.remoteFees) + assert(spliceTxA.tx.sharedOutput.amount == fundingA1 + fundingB1 + additionalFundingA2 + additionalFundingB2) + + assert(commitmentA2.localCommit.spec.toLocal == (fundingA1 + additionalFundingA2).toMilliSatoshi) + assert(commitmentA2.localCommit.spec.toRemote == (fundingB1 + additionalFundingB2).toMilliSatoshi) + assert(commitmentB2.localCommit.spec.toLocal == (fundingB1 + additionalFundingB2).toMilliSatoshi) + assert(commitmentB2.localCommit.spec.toRemote == (fundingA1 + additionalFundingA2).toMilliSatoshi) + + // The resulting transaction is valid and has the right feerate. + walletA.publishTransaction(spliceTxA.signedTx).pipeTo(probe.ref) + probe.expectMsg(spliceTxA.txId) + walletA.getMempoolTx(spliceTxA.txId).pipeTo(probe.ref) + val mempoolTx = probe.expectMsgType[MempoolTx] + assert(mempoolTx.fees == spliceTxA.tx.fees) + assert(targetFeerate <= spliceTxA.feerate && spliceTxA.feerate <= targetFeerate * 1.25, s"unexpected feerate (target=$targetFeerate actual=${spliceTxA.feerate})") + } + } + test("remove input/output") { - assume(!channelType.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat]) // TODO: make this test work with taproot channels ? - withFixture(100_000 sat, Seq(150_000 sat), 0 sat, Nil, FeeratePerKw(2500 sat), 330 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => + withFixture(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), 100_000 sat, Seq(150_000 sat), 0 sat, Nil, FeeratePerKw(2500 sat), 330 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => import f._ alice ! Start(alice2bob.ref) @@ -1149,7 +1258,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit } test("not enough funds (unconfirmed utxos not allowed)") { - withFixture(100_000 sat, Seq(250_000 sat), 0 sat, Nil, FeeratePerKw(2500 sat), 660 sat, 0, RequireConfirmedInputs(forLocal = true, forRemote = true)) { f => + withFixture(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), 100_000 sat, Seq(250_000 sat), 0 sat, Nil, FeeratePerKw(2500 sat), 660 sat, 0, RequireConfirmedInputs(forLocal = true, forRemote = true)) { f => import f._ // Alice's inputs are all unconfirmed. @@ -1175,7 +1284,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit test("not enough funds (unusable utxos)") { val fundingA = 140_000 sat val utxosA = Seq(75_000 sat, 60_000 sat) - withFixture(fundingA, utxosA, 0 sat, Nil, FeeratePerKw(5000 sat), 660 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => + withFixture(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), fundingA, utxosA, 0 sat, Nil, FeeratePerKw(5000 sat), 660 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => import f._ import fr.acinq.bitcoin.scalacompat.KotlinUtils._ @@ -1223,7 +1332,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit test("skip unusable utxos") { val fundingA = 140_000 sat val utxosA = Seq(55_000 sat, 65_000 sat, 50_000 sat) - withFixture(fundingA, utxosA, 0 sat, Nil, FeeratePerKw(5000 sat), 660 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => + withFixture(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), fundingA, utxosA, 0 sat, Nil, FeeratePerKw(5000 sat), 660 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => import f._ // Add some unusable utxos to Alice's wallet. @@ -1288,7 +1397,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit val targetFeerate = FeeratePerKw(7500 sat) val fundingA = 85_000 sat val utxosA = Seq(120_000 sat) - withFixture(fundingA, utxosA, 0 sat, Nil, targetFeerate, 660 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => + withFixture(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), fundingA, utxosA, 0 sat, Nil, targetFeerate, 660 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => import f._ alice ! Start(alice2bob.ref) @@ -1357,7 +1466,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit val targetFeerate = FeeratePerKw(10_000 sat) val fundingA = 100_000 sat val utxosA = Seq(55_000 sat, 55_000 sat, 55_000 sat) - withFixture(fundingA, utxosA, 0 sat, Nil, targetFeerate, 660 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => + withFixture(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), fundingA, utxosA, 0 sat, Nil, targetFeerate, 660 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => import f._ alice ! Start(alice2bob.ref) @@ -1439,7 +1548,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit val utxosA = Seq(70_000 sat, 60_000 sat) val fundingB = 25_000 sat val utxosB = Seq(27_500 sat) - withFixture(fundingA, utxosA, fundingB, utxosB, initialFeerate, 660 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => + withFixture(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), fundingA, utxosA, fundingB, utxosB, initialFeerate, 660 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => import f._ alice ! Start(alice2bob.ref) @@ -1523,7 +1632,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit val utxosA = Seq(480_000 sat, 75_000 sat) val fundingB1 = 100_000 sat val utxosB = Seq(325_000 sat, 60_000 sat) - withFixture(fundingA1, utxosA, fundingB1, utxosB, targetFeerate, 660 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => + withFixture(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), fundingA1, utxosA, fundingB1, utxosB, targetFeerate, 660 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => import f._ val probe = TestProbe() @@ -1650,7 +1759,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit val utxosA = Seq(340_000 sat, 40_000 sat, 35_000 sat) val fundingB1 = 80_000 sat val utxosB = Seq(280_000 sat, 20_000 sat, 15_000 sat) - withFixture(fundingA1, utxosA, fundingB1, utxosB, targetFeerate, 660 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => + withFixture(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), fundingA1, utxosA, fundingB1, utxosB, targetFeerate, 660 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => import f._ val probe = TestProbe() @@ -1785,7 +1894,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit val utxosA = Seq(150_000 sat) val fundingB = 92_000 sat val utxosB = Seq(50_000 sat, 50_000 sat, 50_000 sat, 50_000 sat) - withFixture(fundingA, utxosA, fundingB, utxosB, targetFeerate, 660 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => + withFixture(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), fundingA, utxosA, fundingB, utxosB, targetFeerate, 660 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => import f._ val probe = TestProbe() @@ -1821,10 +1930,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit val purchase = LiquidityAds.Purchase.Standard(50_000 sat, LiquidityAds.Fees(1000 sat, 1500 sat), LiquidityAds.PaymentDetails.FromChannelBalance) // Alice pays fees for the common fields of the transaction, by decreasing her balance in the shared output. val spliceFeeA = { - val dummyWitness: ScriptWitness = channelType.commitmentFormat match { - case _: SimpleTaprootChannelCommitmentFormat => Script.witnessKeyPathPay2tr(Transactions.PlaceHolderSig) - case _ => Scripts.witness2of2(Transactions.PlaceHolderSig, Transactions.PlaceHolderSig, randomKey().publicKey, randomKey().publicKey) - } + val dummyWitness = Scripts.witness2of2(Transactions.PlaceHolderSig, Transactions.PlaceHolderSig, randomKey().publicKey, randomKey().publicKey) val dummySpliceTx = Transaction( version = 2, txIn = Seq(TxIn(commitmentA1.fundingInput, ByteVector.empty, 0, dummyWitness)), @@ -1864,7 +1970,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit val (spliceTxA1, commitmentA2, _, commitmentB2) = fixtureParams.exchangeSigsBobFirst(fundingParamsB1, successA2, successB2) assert(commitmentA2.localCommit.spec.toLocal == commitmentA1.localCommit.spec.toLocal - spliceFeeA - purchase.fees.total) assert(commitmentB2.localCommit.spec.toLocal == commitmentB1.localCommit.spec.toLocal + fundingB + purchase.fees.total) - assert(targetFeerate * 0.9 <= spliceTxA1.feerate && spliceTxA1.feerate <= targetFeerate * 1.27) + assert(targetFeerate * 0.9 <= spliceTxA1.feerate && spliceTxA1.feerate <= targetFeerate * 1.25) walletA.publishTransaction(spliceTxA1.signedTx).pipeTo(probe.ref) probe.expectMsg(spliceTxA1.txId) @@ -1881,7 +1987,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit val utxosA = Seq(340_000 sat, 40_000 sat, 35_000 sat) val fundingB1 = 80_000 sat val utxosB = Seq(290_000 sat, 20_000 sat, 15_000 sat) - withFixture(fundingA1, utxosA, fundingB1, utxosB, targetFeerate, 660 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => + withFixture(ChannelTypes.SimpleTaprootChannelsStaging(), fundingA1, utxosA, fundingB1, utxosB, targetFeerate, 660 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => import f._ val probe = TestProbe() @@ -2035,7 +2141,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit val targetFeerate = FeeratePerKw(10_000 sat) val fundingA = 80_000 sat val utxosA = Seq(85_000 sat) - withFixture(fundingA, utxosA, 0 sat, Nil, targetFeerate, 660 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => + withFixture(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), fundingA, utxosA, 0 sat, Nil, targetFeerate, 660 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => import f._ alice ! Start(alice2bob.ref) @@ -2064,7 +2170,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit } test("allow unconfirmed remote inputs") { - withFixture(120_000 sat, Seq(150_000 sat), 50_000 sat, Seq(100_000 sat), FeeratePerKw(4000 sat), 660 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => + withFixture(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), 120_000 sat, Seq(150_000 sat), 50_000 sat, Seq(100_000 sat), FeeratePerKw(4000 sat), 660 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => import f._ // Bob's available utxo is unconfirmed. @@ -2100,7 +2206,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit } test("reject unconfirmed remote inputs") { - withFixture(120_000 sat, Seq(150_000 sat), 50_000 sat, Seq(100_000 sat), FeeratePerKw(4000 sat), 660 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = true)) { f => + withFixture(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), 120_000 sat, Seq(150_000 sat), 50_000 sat, Seq(100_000 sat), FeeratePerKw(4000 sat), 660 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = true)) { f => import f._ // Bob's available utxo is unconfirmed. @@ -2132,7 +2238,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit } test("funding amount drops below reserve") { - withFixture(500_000 sat, Seq(600_000 sat), 400_000 sat, Seq(450_000 sat), FeeratePerKw(1000 sat), 330 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => + withFixture(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), 500_000 sat, Seq(600_000 sat), 400_000 sat, Seq(450_000 sat), FeeratePerKw(1000 sat), 330 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => import f._ val probe = TestProbe() @@ -2194,8 +2300,8 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit } } - test("invalid tx_signatures (missing shared input signature)") { - withFixture(150_000 sat, Seq(200_000 sat), 0 sat, Nil, FeeratePerKw(1000 sat), 330 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => + private def testTxSignaturesMissingSharedInputSigs(channelType: SupportedChannelType): Unit = { + withFixture(channelType, 150_000 sat, Seq(200_000 sat), 0 sat, Nil, FeeratePerKw(1000 sat), 330 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => import f._ val probe = TestProbe() @@ -2261,8 +2367,16 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit } } + test("invalid tx_signatures (missing shared input signature)") { + testTxSignaturesMissingSharedInputSigs(ChannelTypes.AnchorOutputsZeroFeeHtlcTx()) + } + + test("invalid tx_signatures (missing shared input signature, taproot)") { + testTxSignaturesMissingSharedInputSigs(ChannelTypes.SimpleTaprootChannelsStaging()) + } + test("invalid commitment index") { - withFixture(150_000 sat, Seq(200_000 sat), 0 sat, Nil, FeeratePerKw(1000 sat), 330 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => + withFixture(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), 150_000 sat, Seq(200_000 sat), 0 sat, Nil, FeeratePerKw(1000 sat), 330 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => import f._ val probe = TestProbe() @@ -2338,7 +2452,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit test("invalid funding contributions") { val probe = TestProbe() val wallet = new SingleKeyOnChainWallet() - val params = createFixtureParams(75_000 sat, 25_000 sat, FeeratePerKw(5000 sat), 500 sat, 0) + val params = createFixtureParams(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), 75_000 sat, 25_000 sat, FeeratePerKw(5000 sat), 500 sat, 0) val previousCommitment = CommitmentsSpec.makeCommitments(25_000_000 msat, 75_000_000 msat).active.head val sharedInput = params.dummySharedInputB(100_000 sat) val testCases = Seq( @@ -2358,7 +2472,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit val probe = TestProbe() val wallet = new SingleKeyOnChainWallet() val purchase = LiquidityAds.Purchase.Standard(500_000 sat, LiquidityAds.Fees(5000 sat, 20_000 sat), LiquidityAds.PaymentDetails.FromChannelBalance) - val params = createFixtureParams(24_000 sat, 500_000 sat, FeeratePerKw(5000 sat), 500 sat, 0) + val params = createFixtureParams(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), 24_000 sat, 500_000 sat, FeeratePerKw(5000 sat), 500 sat, 0) // Bob will reject Alice's proposal, since she doesn't have enough funds to pay the liquidity fees. val bob = params.spawnTxBuilderBob(wallet, params.fundingParamsB, Some(purchase)) bob ! Start(probe.ref) @@ -2395,7 +2509,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit ) val previousTx = Transaction(2, Nil, previousOutputs, 0) val wallet = new SingleKeyOnChainWallet() - val params = createFixtureParams(100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) + val params = createFixtureParams(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), 100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) val testCases = Seq( TxAddInput(params.channelId, UInt64(0), Some(previousTx), 0, 0) -> InvalidSerialId(params.channelId, UInt64(0)), TxAddInput(params.channelId, UInt64(1), Some(previousTx), 0, 0) -> DuplicateSerialId(params.channelId, UInt64(1)), @@ -2424,7 +2538,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit test("allow standard output types") { val probe = TestProbe() val wallet = new SingleKeyOnChainWallet() - val params = createFixtureParams(100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) + val params = createFixtureParams(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), 100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) val testCases = Seq( TxAddOutput(params.channelId, UInt64(1), 25_000 sat, Script.write(Script.pay2pkh(randomKey().publicKey))), TxAddOutput(params.channelId, UInt64(1), 25_000 sat, Script.write(Script.pay2sh(OP_1 :: Nil))), @@ -2447,7 +2561,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit test("invalid output") { val probe = TestProbe() val wallet = new SingleKeyOnChainWallet() - val params = createFixtureParams(100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) + val params = createFixtureParams(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), 100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) val validScript = Script.write(Script.pay2wpkh(randomKey().publicKey)) val testCases = Seq( TxAddOutput(params.channelId, UInt64(0), 25_000 sat, validScript) -> InvalidSerialId(params.channelId, UInt64(0)), @@ -2473,7 +2587,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit test("remove unknown input/output") { val probe = TestProbe() val wallet = new SingleKeyOnChainWallet() - val params = createFixtureParams(100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) + val params = createFixtureParams(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), 100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) val testCases = Seq( TxRemoveOutput(params.channelId, UInt64(53)) -> UnknownSerialId(params.channelId, UInt64(53)), TxRemoveInput(params.channelId, UInt64(57)) -> UnknownSerialId(params.channelId, UInt64(57)), @@ -2493,7 +2607,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit test("too many protocol rounds") { val probe = TestProbe() val wallet = new SingleKeyOnChainWallet() - val params = createFixtureParams(100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) + val params = createFixtureParams(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), 100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) val validScript = Script.write(Script.pay2wpkh(randomKey().publicKey)) val alice = params.spawnTxBuilderAlice(wallet) alice ! Start(probe.ref) @@ -2511,7 +2625,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit test("too many inputs") { val probe = TestProbe() val wallet = new SingleKeyOnChainWallet() - val params = createFixtureParams(100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) + val params = createFixtureParams(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), 100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) val alice = params.spawnTxBuilderAlice(wallet) alice ! Start(probe.ref) (1 to 252).foreach(i => { @@ -2528,7 +2642,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit test("too many outputs") { val probe = TestProbe() val wallet = new SingleKeyOnChainWallet() - val params = createFixtureParams(100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) + val params = createFixtureParams(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), 100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) val validScript = Script.write(Script.pay2wpkh(randomKey().publicKey)) val alice = params.spawnTxBuilderAlice(wallet) alice ! Start(probe.ref) @@ -2546,7 +2660,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit test("missing funding output") { val probe = TestProbe() val wallet = new SingleKeyOnChainWallet() - val params = createFixtureParams(100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) + val params = createFixtureParams(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), 100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) val validScript = Script.write(Script.pay2wpkh(randomKey().publicKey)) val bob = params.spawnTxBuilderBob(wallet) bob ! Start(probe.ref) @@ -2566,7 +2680,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit test("multiple funding outputs") { val probe = TestProbe() val wallet = new SingleKeyOnChainWallet() - val params = createFixtureParams(100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) + val params = createFixtureParams(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), 100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) val bob = params.spawnTxBuilderBob(wallet) bob ! Start(probe.ref) // Alice --- tx_add_input --> Bob @@ -2589,7 +2703,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit test("missing shared input") { val probe = TestProbe() val wallet = new SingleKeyOnChainWallet() - val params = createFixtureParams(100_000 sat, 0 sat, FeeratePerKw(1000 sat), 330 sat, 0) + val params = createFixtureParams(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), 100_000 sat, 0 sat, FeeratePerKw(1000 sat), 330 sat, 0) val commitment = CommitmentsSpec.makeCommitments(250_000_000 msat, 150_000_000 msat).active.head val fundingParamsB = params.fundingParamsB.copy(sharedInput_opt = Some(params.dummySharedInputB(commitment.capacity))) val bob = params.spawnTxBuilderSpliceBob(fundingParamsB, commitment, wallet) @@ -2610,7 +2724,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit test("invalid funding amount") { val probe = TestProbe() val wallet = new SingleKeyOnChainWallet() - val params = createFixtureParams(100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) + val params = createFixtureParams(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), 100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) val bob = params.spawnTxBuilderBob(wallet) bob ! Start(probe.ref) // Alice --- tx_add_input --> Bob @@ -2625,7 +2739,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit test("missing previous tx") { val probe = TestProbe() val wallet = new SingleKeyOnChainWallet() - val params = createFixtureParams(100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) + 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) @@ -2640,7 +2754,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit test("invalid shared input") { val probe = TestProbe() val wallet = new SingleKeyOnChainWallet() - val params = createFixtureParams(100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) + 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 fundingTx = Transaction(2, Nil, Seq(TxOut(50_000 sat, Script.pay2wpkh(randomKey().publicKey)), TxOut(20_000 sat, Script.pay2wpkh(randomKey().publicKey))), 0) val sharedInput = SharedFundingInput(InputInfo(OutPoint(fundingTx, 0), fundingTx.txOut.head), 0, randomKey().publicKey, previousCommitment.commitmentFormat) @@ -2656,7 +2770,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit test("total input amount too low") { val probe = TestProbe() val wallet = new SingleKeyOnChainWallet() - val params = createFixtureParams(100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) + val params = createFixtureParams(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), 100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) val validScript = Script.write(Script.pay2wpkh(randomKey().publicKey)) val bob = params.spawnTxBuilderBob(wallet) bob ! Start(probe.ref) @@ -2680,7 +2794,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit test("minimum fee not met") { val probe = TestProbe() val wallet = new SingleKeyOnChainWallet() - val params = createFixtureParams(100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) + val params = createFixtureParams(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), 100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) val validScript = Script.write(Script.pay2wpkh(randomKey().publicKey)) val bob = params.spawnTxBuilderBob(wallet) bob ! Start(probe.ref) @@ -2705,7 +2819,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit val targetFeerate = FeeratePerKw(7500 sat) val fundingA = 85_000 sat val utxosA = Seq(120_000 sat) - withFixture(fundingA, utxosA, 0 sat, Nil, targetFeerate, 660 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => + withFixture(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), fundingA, utxosA, 0 sat, Nil, targetFeerate, 660 sat, 0, RequireConfirmedInputs(forLocal = false, forRemote = false)) { f => import f._ alice ! Start(alice2bob.ref) @@ -2766,7 +2880,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit test("invalid commit_sig") { val (alice2bob, bob2alice) = (TestProbe(), TestProbe()) val wallet = new SingleKeyOnChainWallet() - val params = createFixtureParams(100_000 sat, 25_000 sat, FeeratePerKw(5000 sat), 330 sat, 0) + val params = createFixtureParams(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), 100_000 sat, 25_000 sat, FeeratePerKw(5000 sat), 330 sat, 0) val alice = params.spawnTxBuilderAlice(wallet) val bob = params.spawnTxBuilderBob(wallet) alice ! Start(alice2bob.ref) @@ -2784,13 +2898,35 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit bob ! ReceiveMessage(alice2bob.expectMsgType[SendMessage].msg.asInstanceOf[TxComplete]) // Alice <-- commit_sig --- Bob val successA1 = alice2bob.expectMsgType[Succeeded] - val invalidCommitSig = channelType.commitmentFormat match { - case _: SimpleTaprootChannelCommitmentFormat => - val priv = randomKey() - val (_, nonce) = Musig2.generateNonce(randomBytes32(), Left(priv), Seq(priv.publicKey), None, None) - CommitSig(params.channelId, PartialSignatureWithNonce(ByteVector32.Zeroes, nonce), Nil, batchSize = 1) - case _ => CommitSig(params.channelId, IndividualSignature(ByteVector64.Zeroes), Nil) - } + val invalidCommitSig = CommitSig(params.channelId, IndividualSignature(ByteVector64.Zeroes), Nil) + val Left(error) = successA1.signingSession.receiveCommitSig(params.channelParamsA, params.channelKeysA, invalidCommitSig, params.nodeParamsA.currentBlockHeight)(akka.event.NoLogging) + assert(error.isInstanceOf[InvalidCommitmentSignature]) + } + + test("invalid commit_sig (taproot)") { + val (alice2bob, bob2alice) = (TestProbe(), TestProbe()) + val wallet = new SingleKeyOnChainWallet() + val params = createFixtureParams(ChannelTypes.SimpleTaprootChannelsPhoenix(), 100_000 sat, 25_000 sat, FeeratePerKw(5000 sat), 330 sat, 0) + val alice = params.spawnTxBuilderAlice(wallet) + val bob = params.spawnTxBuilderBob(wallet) + alice ! Start(alice2bob.ref) + bob ! Start(bob2alice.ref) + // Alice --- tx_add_input --> Bob + bob ! ReceiveMessage(alice2bob.expectMsgType[SendMessage].msg.asInstanceOf[TxAddInput]) + alice ! ReceiveMessage(bob2alice.expectMsgType[SendMessage].msg.asInstanceOf[TxAddInput]) + // Alice --- tx_add_output --> Bob + bob ! ReceiveMessage(alice2bob.expectMsgType[SendMessage].msg.asInstanceOf[TxAddOutput]) + alice ! ReceiveMessage(bob2alice.expectMsgType[SendMessage].msg.asInstanceOf[TxAddOutput]) + // Alice --- tx_add_output --> Bob + bob ! ReceiveMessage(alice2bob.expectMsgType[SendMessage].msg.asInstanceOf[TxAddOutput]) + val txCompleteBob = bob2alice.expectMsgType[SendMessage].msg.asInstanceOf[TxComplete] + assert(txCompleteBob.nonces_opt.nonEmpty) + alice ! ReceiveMessage(txCompleteBob) + // Alice --- tx_complete --> Bob + bob ! ReceiveMessage(alice2bob.expectMsgType[SendMessage].msg.asInstanceOf[TxComplete]) + // Alice <-- commit_sig --- Bob + val successA1 = alice2bob.expectMsgType[Succeeded] + val invalidCommitSig = CommitSig(params.channelId, PartialSignatureWithNonce(randomBytes32(), txCompleteBob.nonces_opt.get.commitNonce), Nil, batchSize = 1) val Left(error) = successA1.signingSession.receiveCommitSig(params.channelParamsA, params.channelKeysA, invalidCommitSig, params.nodeParamsA.currentBlockHeight)(akka.event.NoLogging) assert(error.isInstanceOf[InvalidCommitmentSignature]) } @@ -2798,7 +2934,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit test("receive tx_signatures before commit_sig") { val (alice2bob, bob2alice) = (TestProbe(), TestProbe()) val wallet = new SingleKeyOnChainWallet() - val params = createFixtureParams(100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) + val params = createFixtureParams(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), 100_000 sat, 0 sat, FeeratePerKw(5000 sat), 330 sat, 0) val alice = params.spawnTxBuilderAlice(wallet) val bob = params.spawnTxBuilderBob(wallet) alice ! Start(alice2bob.ref) @@ -2824,7 +2960,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit test("invalid tx_signatures") { val (alice2bob, bob2alice) = (TestProbe(), TestProbe()) val wallet = new SingleKeyOnChainWallet() - val params = createFixtureParams(100_000 sat, 25_000 sat, FeeratePerKw(5000 sat), 330 sat, 0) + val params = createFixtureParams(ChannelTypes.AnchorOutputsZeroFeeHtlcTx(), 100_000 sat, 25_000 sat, FeeratePerKw(5000 sat), 330 sat, 0) val alice = params.spawnTxBuilderAlice(wallet) val bob = params.spawnTxBuilderBob(wallet) alice ! Start(alice2bob.ref) @@ -2895,7 +3031,3 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit class InteractiveTxBuilderWithEclairSignerSpec extends InteractiveTxBuilderSpec { override def useEclairSigner = true } - -class InteractiveTxBuilderWithTaprootChannelsSpec extends InteractiveTxBuilderSpec { - override val channelType: SupportedChannelType = ChannelTypes.SimpleTaprootChannelsStaging() -} \ No newline at end of file From bae20095beab325c90770ebe7d4b4eb9371e8540 Mon Sep 17 00:00:00 2001 From: t-bast Date: Fri, 8 Aug 2025 10:18:56 +0200 Subject: [PATCH 15/18] Add missing taproot channel tests The base branch simply duplicated some test suites for taproot without any changes: this gave a false sense of safety, as it actually simply ran twice a lot of tests that exercised the same code paths that didn't have anything related to taproot. It also made the test suite slower, since the duplicated tests were some of our slowest tests. We revert this test suite duplication and instead add tests specific to taproot channels. This showed that there were a lot of bugs in subtle edge cases that weren't discovered previously: - simple close RBF didn't work at all (nonce management was missing) - shutting down a taproot channels that had pending HTLCs didn't work at all either (nonce management wasn't handled) - reconnecting a channel during mutual close lead to a force-close - reconnection with partially signed interactive-tx session wasn't properly checking the next commit nonce for the partially signed tx We also correctly test all scenarios of revoked commitments during and after a commitment upgrade to taproot. Thankfully, this scenario didn't require any code changes, since a revoked commitment that doesn't spend the latest funding transaction must be kept in either our `active` or `inactive` commitments list, which means we have access to the format and parameters of this specific commitment and don't need to brute-force various commitment formats. --- .../fr/acinq/eclair/channel/Commitments.scala | 4 +- .../fr/acinq/eclair/channel/Helpers.scala | 16 +- .../fr/acinq/eclair/channel/fsm/Channel.scala | 84 ++- .../channel/fund/InteractiveTxBuilder.scala | 5 +- .../ChannelStateTestsHelperMethods.scala | 4 +- .../WaitForDualFundingCreatedStateSpec.scala | 53 +- .../b/WaitForDualFundingSignedStateSpec.scala | 220 ++++-- .../b/WaitForFundingSignedStateSpec.scala | 20 +- ...WaitForDualFundingConfirmedStateSpec.scala | 214 +++++- .../states/e/NormalSplicesStateSpec.scala | 673 +++++++++++++++--- .../channel/states/e/NormalStateSpec.scala | 197 ++++- .../channel/states/f/ShutdownStateSpec.scala | 101 ++- .../states/g/NegotiatingStateSpec.scala | 123 +++- .../channel/states/h/ClosingStateSpec.scala | 219 +++--- 14 files changed, 1553 insertions(+), 380 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala index 4d802e2255..be09c584e6 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala @@ -1153,8 +1153,8 @@ case class Commitments(channelParams: ChannelParams, remoteNextCommitInfo match { case Right(_) => Left(UnexpectedRevocation(channelId)) case Left(_) if revocation.perCommitmentSecret.publicKey != active.head.remoteCommit.remotePerCommitmentPoint => Left(InvalidRevocation(channelId)) - case Left(_) if active.exists(c => c.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] && !revocation.nextCommitNonces.contains(c.fundingTxId)) => - val missingNonce = active.find(c => c.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] && !revocation.nextCommitNonces.contains(c.fundingTxId)).get + case Left(_) if active.exists(c => c.commitmentFormat.isInstanceOf[TaprootCommitmentFormat] && !revocation.nextCommitNonces.contains(c.fundingTxId)) => + val missingNonce = active.find(c => c.commitmentFormat.isInstanceOf[TaprootCommitmentFormat] && !revocation.nextCommitNonces.contains(c.fundingTxId)).get Left(MissingCommitNonce(channelId, missingNonce.fundingTxId, remoteCommitIndex + 1)) case Left(_) => // Since htlcs are shared across all commitments, we generate the actions only once based on the first commitment. diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala index b277437a92..a1643e37cd 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala @@ -26,6 +26,7 @@ import fr.acinq.eclair.blockchain.fee._ import fr.acinq.eclair.channel.ChannelSpendSignature.{IndividualSignature, PartialSignatureWithNonce} import fr.acinq.eclair.channel.fsm.Channel import fr.acinq.eclair.channel.fsm.Channel.REFRESH_CHANNEL_UPDATE_INTERVAL +import fr.acinq.eclair.channel.fund.InteractiveTxSigningSession import fr.acinq.eclair.crypto.keymanager.{ChannelKeys, LocalCommitmentKeys, RemoteCommitmentKeys} import fr.acinq.eclair.crypto.{NonceGenerator, ShaChain} import fr.acinq.eclair.db.ChannelsDb @@ -581,10 +582,15 @@ object Helpers { } } - def checkCommitNonces(channelReestablish: ChannelReestablish, commitments: Commitments): Option[ChannelException] = { - commitments.active - .find(c => c.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] && !channelReestablish.nextCommitNonces.contains(c.fundingTxId)) - .map(c => MissingCommitNonce(commitments.channelId, c.fundingTxId, c.remoteCommit.index + 1)) + def checkCommitNonces(channelReestablish: ChannelReestablish, commitments: Commitments, pendingSig_opt: Option[InteractiveTxSigningSession.WaitingForSigs]): Option[ChannelException] = { + pendingSig_opt match { + case Some(pendingSig) if pendingSig.fundingParams.commitmentFormat.isInstanceOf[TaprootCommitmentFormat] && !channelReestablish.nextCommitNonces.contains(pendingSig.fundingTxId) => + Some(MissingCommitNonce(commitments.channelId, pendingSig.fundingTxId, commitments.remoteCommitIndex + 1)) + case _ => + commitments.active + .find(c => c.commitmentFormat.isInstanceOf[TaprootCommitmentFormat] && !channelReestablish.nextCommitNonces.contains(c.fundingTxId)) + .map(c => MissingCommitNonce(commitments.channelId, c.fundingTxId, commitments.remoteCommitIndex + 1)) + } } } @@ -755,7 +761,7 @@ object Helpers { } /** We are the closer: we sign closing transactions for which we pay the fees. */ - def makeSimpleClosingTx(currentBlockHeight: BlockHeight, channelKeys: ChannelKeys, commitment: FullCommitment, localScriptPubkey: ByteVector, remoteScriptPubkey: ByteVector, feerate: FeeratePerKw, remoteNonce_opt: Option[IndividualNonce] = None): Either[ChannelException, (ClosingTxs, ClosingComplete, CloserNonces)] = { + def makeSimpleClosingTx(currentBlockHeight: BlockHeight, channelKeys: ChannelKeys, commitment: FullCommitment, localScriptPubkey: ByteVector, remoteScriptPubkey: ByteVector, feerate: FeeratePerKw, remoteNonce_opt: Option[IndividualNonce]): Either[ChannelException, (ClosingTxs, ClosingComplete, CloserNonces)] = { // We must convert the feerate to a fee: we must build dummy transactions to compute their weight. val commitInput = commitment.commitInput(channelKeys) val closingFee = { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala index 053a0a79fc..7fee82e87c 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala @@ -801,6 +801,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall // we did not send a shutdown message // there are pending signed changes => go to SHUTDOWN // there are no htlcs => go to NEGOTIATING + remoteCloseeNonce_opt = remoteShutdown.closeeNonce_opt if (d.commitments.changes.remoteHasUnsignedOutgoingHtlcs) { handleLocalError(CannotCloseWithUnsignedOutgoingHtlcs(d.channelId), d, Some(remoteShutdown)) } else if (d.commitments.changes.remoteHasUnsignedOutgoingUpdateFee) { @@ -818,10 +819,9 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall } // in the meantime we won't send new changes stay() using d.copy(remoteShutdown = Some(remoteShutdown), closeStatus_opt = Some(CloseStatus.NonInitiator(None))) - } else if (d.commitments.latest.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] && remoteShutdown.closeeNonce_opt.isEmpty) { + } else if (d.commitments.latest.commitmentFormat.isInstanceOf[TaprootCommitmentFormat] && remoteShutdown.closeeNonce_opt.isEmpty) { handleLocalError(MissingClosingNonce(d.channelId), d, Some(remoteShutdown)) } else { - remoteCloseeNonce_opt = remoteShutdown.closeeNonce_opt // so we don't have any unsigned outgoing changes val (localShutdown, sendList) = d.localShutdown match { case Some(localShutdown) => (localShutdown, Nil) @@ -1679,7 +1679,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall context.system.eventStream.publish(ChannelSignatureReceived(self, commitments1)) if (commitments1.hasNoPendingHtlcsOrFeeUpdate) { if (Features.canUseFeature(d.commitments.localChannelParams.initFeatures, d.commitments.remoteChannelParams.initFeatures, Features.SimpleClose)) { - val (d1, closingComplete_opt) = startSimpleClose(d.commitments, localShutdown, remoteShutdown, closeStatus) + val (d1, closingComplete_opt) = startSimpleClose(commitments1, localShutdown, remoteShutdown, closeStatus) goto(NEGOTIATING_SIMPLE) using d1 storing() sending revocation +: closingComplete_opt.toSeq } else if (d.commitments.localChannelParams.paysClosingFees) { // we pay the closing fees, so we initiate the negotiation by sending the first closing_signed @@ -1705,6 +1705,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall d.commitments.receiveRevocation(revocation, nodeParams.onChainFeeConf.feerateToleranceFor(remoteNodeId).dustTolerance.maxExposure) match { case Right((commitments1, actions)) => cancelTimer(RevocationTimeout.toString) + remoteNextCommitNonces = revocation.nextCommitNonces log.debug("received a new rev, spec:\n{}", commitments1.latest.specs2String) actions.foreach { case PostRevocationAction.RelayHtlc(add) => @@ -1724,7 +1725,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall if (commitments1.hasNoPendingHtlcsOrFeeUpdate) { log.debug("switching to NEGOTIATING spec:\n{}", commitments1.latest.specs2String) if (Features.canUseFeature(d.commitments.localChannelParams.initFeatures, d.commitments.remoteChannelParams.initFeatures, Features.SimpleClose)) { - val (d1, closingComplete_opt) = startSimpleClose(d.commitments, localShutdown, remoteShutdown, closeStatus) + val (d1, closingComplete_opt) = startSimpleClose(commitments1, localShutdown, remoteShutdown, closeStatus) goto(NEGOTIATING_SIMPLE) using d1 storing() sending closingComplete_opt.toSeq } else if (d.commitments.localChannelParams.paysClosingFees) { // we pay the closing fees, so we initiate the negotiation by sending the first closing_signed @@ -1747,6 +1748,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall if (shutdown.scriptPubKey != d.remoteShutdown.scriptPubKey) { log.debug("our peer updated their shutdown script (previous={}, current={})", d.remoteShutdown.scriptPubKey, shutdown.scriptPubKey) } + remoteCloseeNonce_opt = shutdown.closeeNonce_opt stay() using d.copy(remoteShutdown = shutdown) storing() case Event(r: RevocationTimeout, d: DATA_SHUTDOWN) => handleRevocationTimeout(r, d) @@ -1757,19 +1759,20 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall case Event(c: CMD_CLOSE, d: DATA_SHUTDOWN) => val useSimpleClose = Features.canUseFeature(d.commitments.localChannelParams.initFeatures, d.commitments.remoteChannelParams.initFeatures, Features.SimpleClose) - val localShutdown_opt = c.scriptPubKey match { - case Some(scriptPubKey) if scriptPubKey != d.localShutdown.scriptPubKey && useSimpleClose => Some(Shutdown(d.channelId, scriptPubKey)) + val nextScriptPubKey_opt = c.scriptPubKey match { + case Some(scriptPubKey) if scriptPubKey != d.localShutdown.scriptPubKey && useSimpleClose => Some(scriptPubKey) case _ => None } if (c.scriptPubKey.exists(_ != d.localShutdown.scriptPubKey) && !useSimpleClose) { handleCommandError(ClosingAlreadyInProgress(d.channelId), c) - } else if (localShutdown_opt.nonEmpty || c.feerates.nonEmpty) { + } else if (nextScriptPubKey_opt.nonEmpty || c.feerates.nonEmpty) { val closeStatus1 = d.closeStatus match { case initiator: CloseStatus.Initiator => initiator.copy(feerates_opt = c.feerates.orElse(initiator.feerates_opt)) case nonInitiator: CloseStatus.NonInitiator => nonInitiator.copy(feerates_opt = c.feerates.orElse(nonInitiator.feerates_opt)) // NB: this is the corner case where we can be non-initiator and have custom feerates } - val d1 = d.copy(localShutdown = localShutdown_opt.getOrElse(d.localShutdown), closeStatus = closeStatus1) - handleCommandSuccess(c, d1) storing() sending localShutdown_opt.toSeq + val shutdown = createShutdown(d.commitments, nextScriptPubKey_opt.getOrElse(d.localShutdown.scriptPubKey)) + val d1 = d.copy(localShutdown = shutdown, closeStatus = closeStatus1) + handleCommandSuccess(c, d1) storing() sending shutdown } else { handleCommandError(ClosingAlreadyInProgress(d.channelId), c) } @@ -1888,6 +1891,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall when(NEGOTIATING_SIMPLE)(handleExceptions { case Event(shutdown: Shutdown, d: DATA_NEGOTIATING_SIMPLE) => + remoteCloseeNonce_opt = shutdown.closeeNonce_opt if (shutdown.scriptPubKey != d.remoteScriptPubKey) { // This may lead to a signature mismatch: peers must use closing_complete to update their closing script. log.warning("received shutdown changing remote script, this may lead to a signature mismatch: previous={}, current={}", d.remoteScriptPubKey, shutdown.scriptPubKey) @@ -1903,7 +1907,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall val err = InvalidRbfFeerate(d.channelId, closingFeerate, d.lastClosingFeerate * 1.2) handleCommandError(err, c) } else { - MutualClose.makeSimpleClosingTx(nodeParams.currentBlockHeight, channelKeys, d.commitments.latest, localScript, d.remoteScriptPubKey, closingFeerate) match { + MutualClose.makeSimpleClosingTx(nodeParams.currentBlockHeight, channelKeys, d.commitments.latest, localScript, d.remoteScriptPubKey, closingFeerate, remoteCloseeNonce_opt) match { case Left(f) => handleCommandError(f, c) case Right((closingTxs, closingComplete, closerNonces)) => log.debug("signing local mutual close transactions: {}", closingTxs) @@ -2399,12 +2403,15 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall case _: SimpleTaprootChannelCommitmentFormat => val localFundingKey = channelKeys.fundingKey(0) val remoteFundingPubKey = d.signingSession.fundingParams.remoteFundingPubKey - val currentCommitNonce = NonceGenerator.verificationNonce(d.signingSession.fundingTxId, localFundingKey, remoteFundingPubKey, 0) + val currentCommitNonce_opt = d.signingSession.localCommit match { + case Left(_) => Some(NonceGenerator.verificationNonce(d.signingSession.fundingTxId, localFundingKey, remoteFundingPubKey, 0)) + case Right(_) => None + } val nextCommitNonce = NonceGenerator.verificationNonce(d.signingSession.fundingTxId, localFundingKey, remoteFundingPubKey, 1) Set( - ChannelReestablishTlv.NextLocalNoncesTlv(List(d.signingSession.fundingTxId -> nextCommitNonce.publicNonce)), - ChannelReestablishTlv.CurrentCommitNonceTlv(currentCommitNonce.publicNonce), - ) + Some(ChannelReestablishTlv.NextLocalNoncesTlv(List(d.signingSession.fundingTxId -> nextCommitNonce.publicNonce))), + currentCommitNonce_opt.map(n => ChannelReestablishTlv.CurrentCommitNonceTlv(n.publicNonce)), + ).flatten[ChannelReestablishTlv] } val channelReestablish = ChannelReestablish( channelId = d.channelId, @@ -2458,23 +2465,26 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall } else Set.empty // We send our verification nonces for all active commitments. - val nextCommitNonces: Map[TxId, IndividualNonce] = d.commitments.active.collect { - case c: Commitment if c.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => - val localFundingKey = channelKeys.fundingKey(c.fundingTxIndex) - c.fundingTxId -> NonceGenerator.verificationNonce(c.fundingTxId, localFundingKey, c.remoteFundingPubKey, d.commitments.localCommitIndex + 1).publicNonce - }.toMap + val nextCommitNonces: Map[TxId, IndividualNonce] = d.commitments.active.flatMap(c => { + c.commitmentFormat match { + case _: SegwitV0CommitmentFormat => None + case _: SimpleTaprootChannelCommitmentFormat => + val localFundingKey = channelKeys.fundingKey(c.fundingTxIndex) + Some(c.fundingTxId -> NonceGenerator.verificationNonce(c.fundingTxId, localFundingKey, c.remoteFundingPubKey, d.commitments.localCommitIndex + 1).publicNonce) + } + }).toMap // If an interactive-tx session hasn't been fully signed, we also need to include the corresponding nonces. val (interactiveTxCurrentCommitNonce_opt, interactiveTxNextCommitNonce): (Option[IndividualNonce], Map[TxId, IndividualNonce]) = d match { case d: DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED => d.status match { - case DualFundingStatus.RbfWaitingForSigs(signingSession) if signingSession.fundingParams.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => + case DualFundingStatus.RbfWaitingForSigs(signingSession) if signingSession.fundingParams.commitmentFormat.isInstanceOf[TaprootCommitmentFormat] => val nextCommitNonce = Map(signingSession.fundingTxId -> signingSession.nextCommitNonce(channelKeys).publicNonce) - (Some(signingSession.currentCommitNonce(channelKeys).publicNonce), nextCommitNonce) + (signingSession.currentCommitNonce_opt(channelKeys).map(_.publicNonce), nextCommitNonce) case _ => (None, Map.empty) } case d: DATA_NORMAL => d.spliceStatus match { - case SpliceStatus.SpliceWaitingForSigs(signingSession) if signingSession.fundingParams.commitmentFormat.isInstanceOf[SimpleTaprootChannelCommitmentFormat] => + case SpliceStatus.SpliceWaitingForSigs(signingSession) if signingSession.fundingParams.commitmentFormat.isInstanceOf[TaprootCommitmentFormat] => val nextCommitNonce = Map(signingSession.fundingTxId -> signingSession.nextCommitNonce(channelKeys).publicNonce) - (Some(signingSession.currentCommitNonce(channelKeys).publicNonce), nextCommitNonce) + (signingSession.currentCommitNonce_opt(channelKeys).map(_.publicNonce), nextCommitNonce) case _ => (None, Map.empty) } case _ => (None, Map.empty) @@ -2523,7 +2533,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall when(SYNCING)(handleExceptions { case Event(channelReestablish: ChannelReestablish, d: DATA_WAIT_FOR_FUNDING_CONFIRMED) => - Helpers.Syncing.checkCommitNonces(channelReestablish, d.commitments) match { + Helpers.Syncing.checkCommitNonces(channelReestablish, d.commitments, None) match { case Some(f) => handleLocalError(f, d, Some(channelReestablish)) case None => remoteNextCommitNonces = channelReestablish.nextCommitNonces @@ -2533,7 +2543,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall case Event(channelReestablish: ChannelReestablish, d: DATA_WAIT_FOR_DUAL_FUNDING_SIGNED) => d.signingSession.fundingParams.commitmentFormat match { case _: SimpleTaprootChannelCommitmentFormat if !channelReestablish.nextCommitNonces.contains(d.signingSession.fundingTxId) => - val f = MissingCommitNonce(d.channelId, d.signingSession.fundingTxId, commitmentNumber = 0) + val f = MissingCommitNonce(d.channelId, d.signingSession.fundingTxId, commitmentNumber = 1) handleLocalError(f, d, Some(channelReestablish)) case _ => remoteNextCommitNonces = channelReestablish.nextCommitNonces @@ -2552,7 +2562,13 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall } case Event(channelReestablish: ChannelReestablish, d: DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED) => - Helpers.Syncing.checkCommitNonces(channelReestablish, d.commitments) match { + val pendingRbf_opt = d.status match { + // Note that we only consider RBF attempts that are also pending for our peer: otherwise it means we have + // disconnected before they sent their commit_sig, in which case they will abort the RBF attempt on reconnection. + case DualFundingStatus.RbfWaitingForSigs(signingSession) if channelReestablish.nextFundingTxId_opt.contains(signingSession.fundingTxId) => Some(signingSession) + case _ => None + } + Helpers.Syncing.checkCommitNonces(channelReestablish, d.commitments, pendingRbf_opt) match { case Some(f) => handleLocalError(f, d, Some(channelReestablish)) case None => remoteNextCommitNonces = channelReestablish.nextCommitNonces @@ -2596,7 +2612,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall } case Event(channelReestablish: ChannelReestablish, d: DATA_WAIT_FOR_CHANNEL_READY) => - Helpers.Syncing.checkCommitNonces(channelReestablish, d.commitments) match { + Helpers.Syncing.checkCommitNonces(channelReestablish, d.commitments, None) match { case Some(f) => handleLocalError(f, d, Some(channelReestablish)) case None => remoteNextCommitNonces = channelReestablish.nextCommitNonces @@ -2605,7 +2621,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall } case Event(channelReestablish: ChannelReestablish, d: DATA_WAIT_FOR_DUAL_FUNDING_READY) => - Helpers.Syncing.checkCommitNonces(channelReestablish, d.commitments) match { + Helpers.Syncing.checkCommitNonces(channelReestablish, d.commitments, None) match { case Some(f) => handleLocalError(f, d, Some(channelReestablish)) case None => remoteNextCommitNonces = channelReestablish.nextCommitNonces @@ -2634,7 +2650,13 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall } case Event(channelReestablish: ChannelReestablish, d: DATA_NORMAL) => - Helpers.Syncing.checkCommitNonces(channelReestablish, d.commitments) match { + val pendingSplice_opt = d.spliceStatus match { + // Note that we only consider splices that are also pending for our peer: otherwise it means we have disconnected + // before they sent their commit_sig, in which case they will abort the splice attempt on reconnection. + case SpliceStatus.SpliceWaitingForSigs(signingSession) if channelReestablish.nextFundingTxId_opt.contains(signingSession.fundingTxId) => Some(signingSession) + case _ => None + } + Helpers.Syncing.checkCommitNonces(channelReestablish, d.commitments, pendingSplice_opt) match { case Some(f) => handleLocalError(f, d, Some(channelReestablish)) case None => remoteNextCommitNonces = channelReestablish.nextCommitNonces @@ -2821,7 +2843,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall case Event(c: CMD_UPDATE_RELAY_FEE, d: DATA_NORMAL) => handleUpdateRelayFeeDisconnected(c, d) case Event(channelReestablish: ChannelReestablish, d: DATA_SHUTDOWN) => - Helpers.Syncing.checkCommitNonces(channelReestablish, d.commitments) match { + Helpers.Syncing.checkCommitNonces(channelReestablish, d.commitments, None) match { case Some(f) => handleLocalError(f, d, Some(channelReestablish)) case None => remoteNextCommitNonces = channelReestablish.nextCommitNonces @@ -2853,7 +2875,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall case Event(_: ChannelReestablish, d: DATA_NEGOTIATING_SIMPLE) => // We retransmit our shutdown: we may have updated our script and they may not have received it. - val localShutdown = Shutdown(d.channelId, d.localScriptPubKey) + val localShutdown = createShutdown(d.commitments, d.localScriptPubKey) goto(NEGOTIATING_SIMPLE) using d sending localShutdown // This handler is a workaround for an issue in lnd: starting with versions 0.10 / 0.11, they sometimes fail to send 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 1ea8a92366..ff52d0b9d2 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 @@ -1204,7 +1204,10 @@ object InteractiveTxSigningSession { def commitInput(channelKeys: ChannelKeys): InputInfo = commitInput(localFundingKey(channelKeys)) /** Nonce for the current commitment, which our peer will need if they must re-send their commit_sig for our current commitment transaction. */ - def currentCommitNonce(channelKeys: ChannelKeys): LocalNonce = NonceGenerator.verificationNonce(fundingTxId, localFundingKey(channelKeys), fundingParams.remoteFundingPubKey, localCommitIndex) + def currentCommitNonce_opt(channelKeys: ChannelKeys): Option[LocalNonce] = localCommit match { + case Left(_) => Some(NonceGenerator.verificationNonce(fundingTxId, localFundingKey(channelKeys), fundingParams.remoteFundingPubKey, localCommitIndex)) + case Right(_) => None + } /** Nonce for the next commitment, which our peer will need to sign our next commitment transaction. */ def nextCommitNonce(channelKeys: ChannelKeys): LocalNonce = NonceGenerator.verificationNonce(fundingTxId, localFundingKey(channelKeys), fundingParams.remoteFundingPubKey, localCommitIndex + 1) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala index 61ad6536bd..a06b49b55c 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala @@ -297,8 +297,8 @@ trait ChannelStateTestsBase extends Assertions with Eventually { val channelType = ChannelTypes.defaultFromFeatures(aliceInitFeatures, bobInitFeatures, announceChannel = channelFlags.announceChannel) // those features can only be enabled with AnchorOutputsZeroFeeHtlcTxs, this is to prevent incompatible test configurations - if (tags.contains(ChannelStateTestsTags.ZeroConf)) assert(tags.contains(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs) || tags.contains(ChannelStateTestsTags.OptionSimpleTaproot), "invalid test configuration") - if (tags.contains(ChannelStateTestsTags.ScidAlias)) assert(tags.contains(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs) || tags.contains(ChannelStateTestsTags.OptionSimpleTaproot), "invalid test configuration") + if (tags.contains(ChannelStateTestsTags.ZeroConf)) assert(tags.contains(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs) || tags.contains(ChannelStateTestsTags.AnchorOutputs) || tags.contains(ChannelStateTestsTags.OptionSimpleTaprootPhoenix) || tags.contains(ChannelStateTestsTags.OptionSimpleTaproot), "invalid test configuration") + if (tags.contains(ChannelStateTestsTags.ScidAlias)) assert(tags.contains(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs) || tags.contains(ChannelStateTestsTags.AnchorOutputs) || tags.contains(ChannelStateTestsTags.OptionSimpleTaprootPhoenix) || tags.contains(ChannelStateTestsTags.OptionSimpleTaproot), "invalid test configuration") implicit val ec: scala.concurrent.ExecutionContext = scala.concurrent.ExecutionContext.global val aliceChannelParams = Alice.channelParams diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForDualFundingCreatedStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForDualFundingCreatedStateSpec.scala index 5289c81947..b11293e133 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForDualFundingCreatedStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForDualFundingCreatedStateSpec.scala @@ -38,14 +38,11 @@ class WaitForDualFundingCreatedStateSpec extends TestKitBaseClass with FixtureAn case class FixtureParam(alice: TestFSMRef[ChannelState, ChannelData, Channel], bob: TestFSMRef[ChannelState, ChannelData, Channel], aliceOpenReplyTo: TestProbe, alice2bob: TestProbe, bob2alice: TestProbe, alice2blockchain: TestProbe, bob2blockchain: TestProbe, wallet: SingleKeyOnChainWallet, aliceListener: TestProbe, bobListener: TestProbe) - val extraTags: Set[String] = Set.empty - override def withFixture(test: OneArgTest): Outcome = { val wallet = new SingleKeyOnChainWallet() - val tags = test.tags ++ extraTags - val setup = init(wallet_opt = Some(wallet), tags = tags) + val setup = init(wallet_opt = Some(wallet), tags = test.tags) import setup._ - val channelParams = computeChannelParams(setup, tags) + val channelParams = computeChannelParams(setup, test.tags) val aliceListener = TestProbe() val bobListener = TestProbe() within(30 seconds) { @@ -113,6 +110,26 @@ class WaitForDualFundingCreatedStateSpec extends TestKitBaseClass with FixtureAn aliceOpenReplyTo.expectMsgType[OpenChannelResponse.Rejected] } + test("recv tx_complete without nonces (taproot)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + import f._ + + alice2bob.expectMsgType[TxAddInput] + alice2bob.forward(bob) + bob2alice.expectMsgType[TxAddInput] + bob2alice.forward(alice) + alice2bob.expectMsgType[TxAddOutput] + alice2bob.forward(bob) + bob2alice.expectMsgType[TxAddOutput] + bob2alice.forward(alice) + alice2bob.expectMsgType[TxAddOutput] + alice2bob.forward(bob) + val txComplete = bob2alice.expectMsgType[TxComplete] + assert(txComplete.nonces_opt.isDefined) + bob2alice.forward(alice, txComplete.copy(tlvStream = txComplete.tlvStream.copy(records = txComplete.tlvStream.records.filterNot(_.isInstanceOf[TxCompleteTlv.Nonces])))) + aliceListener.expectMsgType[ChannelAborted] + awaitCond(alice.stateName == CLOSED) + } + test("recv TxAbort", Tag(ChannelStateTestsTags.DualFunding)) { f => import f._ @@ -259,30 +276,4 @@ class WaitForDualFundingCreatedStateSpec extends TestKitBaseClass with FixtureAn aliceOpenReplyTo.expectMsg(OpenChannelResponse.TimedOut) } -} - -class WaitForDualFundingCreatedStateWithTaprootChannelsSpec extends WaitForDualFundingCreatedStateSpec { - override val extraTags: Set[String] = Set(ChannelStateTestsTags.OptionSimpleTaproot) - - test("tx_complete is missing nonces", Tag(ChannelStateTestsTags.DualFunding)) { f => - import f._ - - bob2alice.expectNoMessage(100 millis) - alice2bob.expectMsgType[TxAddInput] - alice2bob.expectNoMessage(100 millis) - alice2bob.forward(bob) - bob2alice.expectMsgType[TxAddInput] - bob2alice.forward(alice) - alice2bob.expectMsgType[TxAddOutput] - alice2bob.forward(bob) - bob2alice.expectMsgType[TxAddOutput] - bob2alice.forward(alice) - alice2bob.expectMsgType[TxAddOutput] - alice2bob.forward(bob) - val txComplete = bob2alice.expectMsgType[TxComplete] - assert(txComplete.nonces_opt.isDefined) - bob2alice.forward(alice, txComplete.copy(tlvStream = txComplete.tlvStream.copy(records = txComplete.tlvStream.records.filterNot(_.isInstanceOf[TxCompleteTlv.Nonces])))) - aliceListener.expectMsgType[ChannelAborted] - awaitCond(alice.stateName == CLOSED) - } } \ No newline at end of file diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForDualFundingSignedStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForDualFundingSignedStateSpec.scala index c18a7266b3..2137dabae3 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForDualFundingSignedStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForDualFundingSignedStateSpec.scala @@ -19,18 +19,20 @@ package fr.acinq.eclair.channel.states.b import akka.testkit.{TestFSMRef, TestProbe} import fr.acinq.bitcoin.scalacompat.{ByteVector64, SatoshiLong, TxId} import fr.acinq.eclair.TestUtils.randomTxId -import fr.acinq.eclair.blockchain.{NewTransaction, SingleKeyOnChainWallet} import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{WatchFundingConfirmed, WatchPublished, WatchPublishedTriggered} import fr.acinq.eclair.blockchain.fee.FeeratePerKw -import fr.acinq.eclair.channel.ChannelSpendSignature.IndividualSignature +import fr.acinq.eclair.blockchain.{NewTransaction, SingleKeyOnChainWallet} +import fr.acinq.eclair.channel.ChannelSpendSignature.{IndividualSignature, PartialSignatureWithNonce} import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fsm.Channel import fr.acinq.eclair.channel.fund.InteractiveTxBuilder.{FullySignedSharedTransaction, PartiallySignedSharedTransaction} import fr.acinq.eclair.channel.publish.TxPublisher import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags} +import fr.acinq.eclair.crypto.NonceGenerator import fr.acinq.eclair.io.Peer.{LiquidityPurchaseSigned, OpenChannelResponse} +import fr.acinq.eclair.transactions.Transactions._ import fr.acinq.eclair.wire.protocol._ -import fr.acinq.eclair.{Features, MilliSatoshiLong, TestConstants, TestKitBaseClass, ToMilliSatoshiConversion} +import fr.acinq.eclair.{Features, MilliSatoshiLong, TestConstants, TestKitBaseClass, ToMilliSatoshiConversion, randomBytes32, randomKey} import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.{Outcome, Tag} @@ -40,18 +42,14 @@ class WaitForDualFundingSignedStateSpec extends TestKitBaseClass with FixtureAny case class FixtureParam(alice: TestFSMRef[ChannelState, ChannelData, Channel], bob: TestFSMRef[ChannelState, ChannelData, Channel], alicePeer: TestProbe, bobPeer: TestProbe, alice2bob: TestProbe, bob2alice: TestProbe, alice2blockchain: TestProbe, bob2blockchain: TestProbe, wallet: SingleKeyOnChainWallet, aliceListener: TestProbe, bobListener: TestProbe) - val extraTags: Set[String] = Set.empty - override def withFixture(test: OneArgTest): Outcome = { val wallet = new SingleKeyOnChainWallet() - val tags = test.tags ++ extraTags - val setup = init(wallet_opt = Some(wallet), tags = tags) + val setup = init(wallet_opt = Some(wallet), tags = test.tags) import setup._ - - val channelParams = computeChannelParams(setup, tags) + val channelParams = computeChannelParams(setup, test.tags) val bobContribution = if (channelParams.channelType.features.contains(Features.ZeroConf)) None else Some(LiquidityAds.AddFunding(TestConstants.nonInitiatorFundingSatoshis, Some(TestConstants.defaultLiquidityRates))) - val requestFunding_opt = if (tags.contains(ChannelStateTestsTags.LiquidityAds)) Some(LiquidityAds.RequestFunding(TestConstants.nonInitiatorFundingSatoshis, TestConstants.defaultLiquidityRates.fundingRates.head, LiquidityAds.PaymentDetails.FromChannelBalance)) else None - val (initiatorPushAmount, nonInitiatorPushAmount) = if (tags.contains("both_push_amount")) (Some(TestConstants.initiatorPushAmount), Some(TestConstants.nonInitiatorPushAmount)) else (None, None) + val requestFunding_opt = if (test.tags.contains(ChannelStateTestsTags.LiquidityAds)) Some(LiquidityAds.RequestFunding(TestConstants.nonInitiatorFundingSatoshis, TestConstants.defaultLiquidityRates.fundingRates.head, LiquidityAds.PaymentDetails.FromChannelBalance)) else None + val (initiatorPushAmount, nonInitiatorPushAmount) = if (test.tags.contains("both_push_amount")) (Some(TestConstants.initiatorPushAmount), Some(TestConstants.nonInitiatorPushAmount)) else (None, None) val aliceListener = TestProbe() val bobListener = TestProbe() within(30 seconds) { @@ -162,16 +160,18 @@ class WaitForDualFundingSignedStateSpec extends TestKitBaseClass with FixtureAny assert(aliceData.latestFundingTx.sharedTx.asInstanceOf[FullySignedSharedTransaction].signedTx.txid == fundingTxId) } - def `complete interactive-tx protocol (with push amount)`(f: FixtureParam): Unit = { + test("complete interactive-tx protocol (with push amount, taproot)", Tag(ChannelStateTestsTags.DualFunding), Tag("both_push_amount"), Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => import f._ val listener = TestProbe() alice.underlyingActor.context.system.eventStream.subscribe(listener.ref, classOf[TransactionPublished]) - bob2alice.expectMsgType[CommitSig] - bob2alice.forward(alice) - alice2bob.expectMsgType[CommitSig] - alice2bob.forward(bob) + val commitSigB = bob2alice.expectMsgType[CommitSig] + assert(commitSigB.sigOrPartialSig.isInstanceOf[PartialSignatureWithNonce]) + bob2alice.forward(alice, commitSigB) + val commitSigA = alice2bob.expectMsgType[CommitSig] + assert(commitSigA.sigOrPartialSig.isInstanceOf[PartialSignatureWithNonce]) + alice2bob.forward(bob, commitSigA) val expectedBalanceAlice = TestConstants.fundingSatoshis.toMilliSatoshi + TestConstants.nonInitiatorPushAmount - TestConstants.initiatorPushAmount assert(expectedBalanceAlice == 900_000_000.msat) @@ -194,10 +194,6 @@ class WaitForDualFundingSignedStateSpec extends TestKitBaseClass with FixtureAny assert(aliceData.commitments.latest.localCommit.spec.toRemote == expectedBalanceBob) } - test("complete interactive-tx protocol (with push amount)", Tag(ChannelStateTestsTags.DualFunding), Tag("both_push_amount")) { f => - `complete interactive-tx protocol (with push amount)`(f) - } - test("complete interactive-tx protocol (with liquidity ads)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.LiquidityAds)) { f => import f._ @@ -242,21 +238,36 @@ class WaitForDualFundingSignedStateSpec extends TestKitBaseClass with FixtureAny val bobCommitSig = bob2alice.expectMsgType[CommitSig] val aliceCommitSig = alice2bob.expectMsgType[CommitSig] - val invalidBobCommitSig = bobCommitSig.sigOrPartialSig match { - case _: ChannelSpendSignature.IndividualSignature => bobCommitSig.copy(signature = IndividualSignature(ByteVector64.Zeroes)) - case psig: ChannelSpendSignature.PartialSignatureWithNonce => bobCommitSig.copy(tlvStream = TlvStream(CommitSigTlv.PartialSignatureWithNonceTlv(psig.copy(partialSig = psig.partialSig.reverse)))) - } - bob2alice.forward(alice, invalidBobCommitSig) + bob2alice.forward(alice, bobCommitSig.copy(signature = IndividualSignature(ByteVector64.Zeroes))) alice2bob.expectMsgType[Error] awaitCond(wallet.rolledback.length == 1) aliceListener.expectMsgType[ChannelAborted] awaitCond(alice.stateName == CLOSED) - val invalidAliceCommitSig = aliceCommitSig.sigOrPartialSig match { - case _: ChannelSpendSignature.IndividualSignature => bobCommitSig.copy(signature = IndividualSignature(ByteVector64.Zeroes)) - case psig: ChannelSpendSignature.PartialSignatureWithNonce => bobCommitSig.copy(tlvStream = TlvStream(CommitSigTlv.PartialSignatureWithNonceTlv(psig.copy(partialSig = psig.partialSig.reverse)))) - } - alice2bob.forward(bob, invalidAliceCommitSig) + alice2bob.forward(bob, aliceCommitSig.copy(signature = IndividualSignature(ByteVector64.Zeroes))) + bob2alice.expectMsgType[Error] + awaitCond(wallet.rolledback.length == 2) + bobListener.expectMsgType[ChannelAborted] + awaitCond(bob.stateName == CLOSED) + } + + test("recv invalid CommitSig (taproot)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + import f._ + + val bobCommitSig = bob2alice.expectMsgType[CommitSig] + assert(bobCommitSig.partialSignature_opt.nonEmpty) + val aliceCommitSig = alice2bob.expectMsgType[CommitSig] + assert(aliceCommitSig.partialSignature_opt.nonEmpty) + + val invalidSigBob = bobCommitSig.partialSignature_opt.get.copy(partialSig = randomBytes32()) + bob2alice.forward(alice, bobCommitSig.copy(tlvStream = TlvStream(CommitSigTlv.PartialSignatureWithNonceTlv(invalidSigBob)))) + alice2bob.expectMsgType[Error] + awaitCond(wallet.rolledback.length == 1) + aliceListener.expectMsgType[ChannelAborted] + awaitCond(alice.stateName == CLOSED) + + val invalidSigAlice = aliceCommitSig.partialSignature_opt.get.copy(nonce = NonceGenerator.signingNonce(randomKey().publicKey, randomKey().publicKey, randomTxId()).publicNonce) + alice2bob.forward(bob, aliceCommitSig.copy(tlvStream = TlvStream(CommitSigTlv.PartialSignatureWithNonceTlv(invalidSigAlice)))) bob2alice.expectMsgType[Error] awaitCond(wallet.rolledback.length == 2) bobListener.expectMsgType[ChannelAborted] @@ -370,7 +381,7 @@ class WaitForDualFundingSignedStateSpec extends TestKitBaseClass with FixtureAny assert(wallet.rolledback.isEmpty) } - test("recv INPUT_DISCONNECTED (commit_sig not received)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + def testReconnectCommitSigNotReceived(f: FixtureParam, commitmentFormat: CommitmentFormat): Unit = { import f._ val fundingTxId = alice.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_SIGNED].signingSession.fundingTx.txId @@ -384,10 +395,60 @@ class WaitForDualFundingSignedStateSpec extends TestKitBaseClass with FixtureAny bob ! INPUT_DISCONNECTED awaitCond(bob.stateName == OFFLINE) - reconnect(f, fundingTxId, aliceExpectsCommitSig = true, bobExpectsCommitSig = true) + reconnect(f, fundingTxId, commitmentFormat, aliceExpectsCommitSig = true, bobExpectsCommitSig = true) } - test("recv INPUT_DISCONNECTED (commit_sig received by Alice)", Tag(ChannelStateTestsTags.DualFunding)) { f => + test("recv INPUT_DISCONNECTED (commit_sig not received)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + testReconnectCommitSigNotReceived(f, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) + } + + test("recv INPUT_DISCONNECTED (commit_sig not received, taproot)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + testReconnectCommitSigNotReceived(f, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) + } + + test("recv INPUT_DISCONNECTED (commit_sig not received, missing taproot commit nonce)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + import f._ + + val fundingTxId = alice.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_SIGNED].signingSession.fundingTx.txId + alice2bob.expectMsgType[CommitSig] // Bob doesn't receive Alice's commit_sig + bob2alice.expectMsgType[CommitSig] // Alice doesn't receive Bob's commit_sig + awaitCond(alice.stateName == WAIT_FOR_DUAL_FUNDING_SIGNED) + awaitCond(bob.stateName == WAIT_FOR_DUAL_FUNDING_SIGNED) + + alice ! INPUT_DISCONNECTED + awaitCond(alice.stateName == OFFLINE) + bob ! INPUT_DISCONNECTED + awaitCond(bob.stateName == OFFLINE) + + val aliceInit = Init(alice.underlyingActor.nodeParams.features.initFeatures()) + val bobInit = Init(bob.underlyingActor.nodeParams.features.initFeatures()) + + alice ! INPUT_RECONNECTED(bob, aliceInit, bobInit) + val channelReestablishAlice = alice2bob.expectMsgType[ChannelReestablish] + assert(channelReestablishAlice.nextLocalCommitmentNumber == 0) + assert(channelReestablishAlice.currentCommitNonce_opt.nonEmpty) + assert(channelReestablishAlice.nextCommitNonces.contains(fundingTxId)) + + bob ! INPUT_RECONNECTED(alice, bobInit, aliceInit) + val channelReestablishBob = bob2alice.expectMsgType[ChannelReestablish] + assert(channelReestablishBob.nextLocalCommitmentNumber == 0) + assert(channelReestablishBob.currentCommitNonce_opt.nonEmpty) + assert(channelReestablishBob.nextCommitNonces.contains(fundingTxId)) + + // If Alice doesn't include her current commit nonce, Bob won't be able to retransmit commit_sig. + val channelReestablishAlice1 = channelReestablishAlice.copy(tlvStream = TlvStream(channelReestablishAlice.tlvStream.records.filterNot(_.isInstanceOf[ChannelReestablishTlv.CurrentCommitNonceTlv]))) + alice2bob.forward(bob, channelReestablishAlice1) + assert(bob2alice.expectMsgType[Error].toAscii == MissingCommitNonce(channelReestablishBob.channelId, fundingTxId, commitmentNumber = 0).getMessage) + awaitCond(bob.stateName == CLOSED) + + // If Bob doesn't include nonces for this next commit, Alice won't be able to update the channel. + val channelReestablishBob1 = channelReestablishBob.copy(tlvStream = TlvStream(channelReestablishBob.tlvStream.records.filterNot(_.isInstanceOf[ChannelReestablishTlv.NextLocalNoncesTlv]))) + bob2alice.forward(alice, channelReestablishBob1) + assert(alice2bob.expectMsgType[Error].toAscii == MissingCommitNonce(channelReestablishBob.channelId, fundingTxId, commitmentNumber = 1).getMessage) + awaitCond(bob.stateName == CLOSED) + } + + def testReconnectCommitSigReceivedByAlice(f: FixtureParam, commitmentFormat: CommitmentFormat): Unit = { import f._ val fundingTxId = alice.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_SIGNED].signingSession.fundingTx.txId @@ -402,10 +463,18 @@ class WaitForDualFundingSignedStateSpec extends TestKitBaseClass with FixtureAny bob ! INPUT_DISCONNECTED awaitCond(bob.stateName == OFFLINE) - reconnect(f, fundingTxId, aliceExpectsCommitSig = false, bobExpectsCommitSig = true) + reconnect(f, fundingTxId, commitmentFormat, aliceExpectsCommitSig = false, bobExpectsCommitSig = true) } - test("recv INPUT_DISCONNECTED (commit_sig received by Bob)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + test("recv INPUT_DISCONNECTED (commit_sig received by Alice)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + testReconnectCommitSigReceivedByAlice(f, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) + } + + test("recv INPUT_DISCONNECTED (commit_sig received by Alice, taproot)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + testReconnectCommitSigReceivedByAlice(f, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) + } + + test("recv INPUT_DISCONNECTED (commit_sig received by Bob)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => import f._ val fundingTxId = alice.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_SIGNED].signingSession.fundingTx.txId @@ -421,10 +490,10 @@ class WaitForDualFundingSignedStateSpec extends TestKitBaseClass with FixtureAny bob ! INPUT_DISCONNECTED awaitCond(bob.stateName == OFFLINE) - reconnect(f, fundingTxId, aliceExpectsCommitSig = true, bobExpectsCommitSig = false) + reconnect(f, fundingTxId, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat, aliceExpectsCommitSig = true, bobExpectsCommitSig = false) } - test("recv INPUT_DISCONNECTED (commit_sig received by Bob, zero-conf)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.ZeroConf), Tag(ChannelStateTestsTags.OptionSimpleTaprootPhoenix)) { f => + test("recv INPUT_DISCONNECTED (commit_sig received by Bob, zero-conf)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.ZeroConf), Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => import f._ alice2bob.expectMsgType[CommitSig] @@ -440,7 +509,8 @@ class WaitForDualFundingSignedStateSpec extends TestKitBaseClass with FixtureAny assert(bob2blockchain.expectMsgType[WatchPublished].txId == fundingTx.txid) bob ! WatchPublishedTriggered(fundingTx) assert(bob2blockchain.expectMsgType[WatchFundingConfirmed].txId == fundingTx.txid) - bob2alice.expectMsgType[ChannelReady] + val channelReadyB = bob2alice.expectMsgType[ChannelReady] + assert(channelReadyB.nextCommitNonce_opt.nonEmpty) awaitCond(bob.stateName == WAIT_FOR_DUAL_FUNDING_READY) alice ! INPUT_DISCONNECTED @@ -456,15 +526,20 @@ class WaitForDualFundingSignedStateSpec extends TestKitBaseClass with FixtureAny alice ! INPUT_RECONNECTED(bob, aliceInit, bobInit) bob ! INPUT_RECONNECTED(alice, bobInit, aliceInit) val channelReestablishAlice = alice2bob.expectMsgType[ChannelReestablish] + assert(channelReestablishAlice.currentCommitNonce_opt.nonEmpty) + assert(channelReestablishAlice.nextCommitNonces.contains(fundingTx.txid)) + assert(channelReestablishAlice.nextCommitNonces.get(fundingTx.txid) != channelReestablishAlice.currentCommitNonce_opt) assert(channelReestablishAlice.nextFundingTxId_opt.contains(fundingTx.txid)) assert(channelReestablishAlice.nextLocalCommitmentNumber == 0) alice2bob.forward(bob, channelReestablishAlice) val channelReestablishBob = bob2alice.expectMsgType[ChannelReestablish] + assert(channelReestablishBob.currentCommitNonce_opt.isEmpty) + assert(channelReestablishBob.nextCommitNonces.get(fundingTx.txid) == channelReadyB.nextCommitNonce_opt) assert(channelReestablishBob.nextFundingTxId_opt.isEmpty) assert(channelReestablishBob.nextLocalCommitmentNumber == 1) bob2alice.forward(alice, channelReestablishBob) - bob2alice.expectMsgType[CommitSig] + assert(bob2alice.expectMsgType[CommitSig].partialSignature_opt.nonEmpty) bob2alice.forward(alice) bob2alice.expectMsgType[TxSignatures] bob2alice.forward(alice) @@ -477,7 +552,7 @@ class WaitForDualFundingSignedStateSpec extends TestKitBaseClass with FixtureAny assert(listener.expectMsgType[TransactionPublished].tx.txid == fundingTx.txid) } - test("recv INPUT_DISCONNECTED (commit_sig received)", Tag(ChannelStateTestsTags.DualFunding)) { f => + test("recv INPUT_DISCONNECTED (commit_sig received)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => import f._ val fundingTxId = alice.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_SIGNED].signingSession.fundingTx.txId @@ -494,7 +569,7 @@ class WaitForDualFundingSignedStateSpec extends TestKitBaseClass with FixtureAny bob ! INPUT_DISCONNECTED awaitCond(bob.stateName == OFFLINE) - reconnect(f, fundingTxId, aliceExpectsCommitSig = false, bobExpectsCommitSig = false) + reconnect(f, fundingTxId, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat, aliceExpectsCommitSig = false, bobExpectsCommitSig = false) } test("recv INPUT_DISCONNECTED (tx_signatures received)", Tag(ChannelStateTestsTags.DualFunding)) { f => @@ -534,7 +609,7 @@ class WaitForDualFundingSignedStateSpec extends TestKitBaseClass with FixtureAny assert(listener.expectMsgType[TransactionPublished].tx.txid == fundingTxId) } - test("recv INPUT_DISCONNECTED (tx_signatures received, zero-conf)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.ZeroConf), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + test("recv INPUT_DISCONNECTED (tx_signatures received, zero-conf)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.ZeroConf), Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => import f._ val listener = TestProbe() @@ -554,7 +629,8 @@ class WaitForDualFundingSignedStateSpec extends TestKitBaseClass with FixtureAny assert(alice2blockchain.expectMsgType[WatchPublished].txId == fundingTx.txid) alice ! WatchPublishedTriggered(fundingTx) assert(alice2blockchain.expectMsgType[WatchFundingConfirmed].txId == fundingTx.txid) - alice2bob.expectMsgType[ChannelReady] + val channelReadyA1 = alice2bob.expectMsgType[ChannelReady] + assert(channelReadyA1.nextCommitNonce_opt.nonEmpty) awaitCond(alice.stateName == WAIT_FOR_DUAL_FUNDING_READY) alice ! INPUT_DISCONNECTED @@ -567,19 +643,26 @@ class WaitForDualFundingSignedStateSpec extends TestKitBaseClass with FixtureAny alice ! INPUT_RECONNECTED(bob, aliceInit, bobInit) bob ! INPUT_RECONNECTED(alice, bobInit, aliceInit) - assert(alice2bob.expectMsgType[ChannelReestablish].nextFundingTxId_opt.isEmpty) - alice2bob.forward(bob) - assert(bob2alice.expectMsgType[ChannelReestablish].nextFundingTxId_opt.contains(fundingTx.txid)) - bob2alice.forward(alice) + val channelReestablishA = alice2bob.expectMsgType[ChannelReestablish] + assert(channelReestablishA.nextFundingTxId_opt.isEmpty) + assert(channelReestablishA.currentCommitNonce_opt.isEmpty) + assert(channelReestablishA.nextCommitNonces.get(fundingTx.txid) == channelReadyA1.nextCommitNonce_opt) + alice2bob.forward(bob, channelReestablishA) + val channelReestablishB = bob2alice.expectMsgType[ChannelReestablish] + assert(channelReestablishB.nextFundingTxId_opt.contains(fundingTx.txid)) + assert(channelReestablishA.currentCommitNonce_opt.isEmpty) + assert(channelReestablishA.nextCommitNonces.contains(fundingTx.txid)) + bob2alice.forward(alice, channelReestablishB) alice2bob.expectMsgType[TxSignatures] alice2bob.forward(bob) - alice2bob.expectMsgType[ChannelReady] - alice2bob.forward(bob) + val channelReadyA2 = alice2bob.expectMsgType[ChannelReady] + assert(channelReadyA2.nextCommitNonce_opt == channelReadyA1.nextCommitNonce_opt) + alice2bob.forward(bob, channelReadyA2) assert(bob2blockchain.expectMsgType[WatchPublished].txId == fundingTx.txid) assert(listener.expectMsgType[TransactionPublished].tx.txid == fundingTx.txid) } - private def reconnect(f: FixtureParam, fundingTxId: TxId, aliceExpectsCommitSig: Boolean, bobExpectsCommitSig: Boolean): Unit = { + private def reconnect(f: FixtureParam, fundingTxId: TxId, commitmentFormat: CommitmentFormat, aliceExpectsCommitSig: Boolean, bobExpectsCommitSig: Boolean): Unit = { import f._ val listener = TestProbe() @@ -600,13 +683,38 @@ class WaitForDualFundingSignedStateSpec extends TestKitBaseClass with FixtureAny assert(channelReestablishBob.nextLocalCommitmentNumber == nextLocalCommitmentNumberBob) bob2alice.forward(alice, channelReestablishBob) + // When using taproot, we must provide nonces for the partial signatures. + commitmentFormat match { + case _: SegwitV0CommitmentFormat => () + case _: SimpleTaprootChannelCommitmentFormat => + Seq((channelReestablishAlice, aliceExpectsCommitSig), (channelReestablishBob, bobExpectsCommitSig)).foreach { + case (channelReestablish, expectCommitSig) => + assert(channelReestablish.nextCommitNonces.size == 1) + assert(channelReestablish.nextCommitNonces.contains(fundingTxId)) + if (expectCommitSig) { + assert(channelReestablish.currentCommitNonce_opt.nonEmpty) + assert(channelReestablish.currentCommitNonce_opt != channelReestablish.nextCommitNonces.get(fundingTxId)) + } else { + assert(channelReestablish.currentCommitNonce_opt.isEmpty) + } + } + } + if (aliceExpectsCommitSig) { - bob2alice.expectMsgType[CommitSig] - bob2alice.forward(alice) + val commitSigBob = bob2alice.expectMsgType[CommitSig] + commitmentFormat match { + case _: SegwitV0CommitmentFormat => assert(commitSigBob.partialSignature_opt.isEmpty) + case _: SimpleTaprootChannelCommitmentFormat => assert(commitSigBob.partialSignature_opt.nonEmpty) + } + bob2alice.forward(alice, commitSigBob) } if (bobExpectsCommitSig) { - alice2bob.expectMsgType[CommitSig] - alice2bob.forward(bob) + val commitSigAlice = alice2bob.expectMsgType[CommitSig] + commitmentFormat match { + case _: SegwitV0CommitmentFormat => assert(commitSigAlice.partialSignature_opt.isEmpty) + case _: SimpleTaprootChannelCommitmentFormat => assert(commitSigAlice.partialSignature_opt.nonEmpty) + } + alice2bob.forward(bob, commitSigAlice) } bob2alice.expectMsgType[TxSignatures] bob2alice.forward(alice) @@ -621,7 +729,3 @@ class WaitForDualFundingSignedStateSpec extends TestKitBaseClass with FixtureAny } } - -class WaitForDualFundingSignedStateWithTaprootChannelsSpec extends WaitForDualFundingSignedStateSpec { - override val extraTags: Set[String] = Set(ChannelStateTestsTags.OptionSimpleTaprootPhoenix) -} \ No newline at end of file diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForFundingSignedStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForFundingSignedStateSpec.scala index eba3d7baff..cb34d82ab0 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForFundingSignedStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForFundingSignedStateSpec.scala @@ -19,16 +19,19 @@ package fr.acinq.eclair.channel.states.b import akka.testkit.{TestFSMRef, TestProbe} import fr.acinq.bitcoin.scalacompat.{Btc, ByteVector32, ByteVector64, SatoshiLong} import fr.acinq.eclair.TestConstants.{Alice, Bob} +import fr.acinq.eclair.TestUtils.randomTxId import fr.acinq.eclair.blockchain.DummyOnChainWallet import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ +import fr.acinq.eclair.channel.ChannelSpendSignature.PartialSignatureWithNonce import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fsm.Channel import fr.acinq.eclair.channel.fsm.Channel.TickChannelOpenTimeout import fr.acinq.eclair.channel.publish.TxPublisher import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags} +import fr.acinq.eclair.crypto.NonceGenerator import fr.acinq.eclair.io.Peer.OpenChannelResponse import fr.acinq.eclair.wire.protocol.{AcceptChannel, Error, FundingCreated, FundingSigned, OpenChannel} -import fr.acinq.eclair.{TestConstants, TestKitBaseClass} +import fr.acinq.eclair.{TestConstants, TestKitBaseClass, randomBytes32, randomKey} import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.{Outcome, Tag} @@ -104,8 +107,9 @@ class WaitForFundingSignedStateSpec extends TestKitBaseClass with FixtureAnyFunS import f._ val listener = TestProbe() alice.underlying.system.eventStream.subscribe(listener.ref, classOf[TransactionPublished]) - bob2alice.expectMsgType[FundingSigned] - bob2alice.forward(alice) + val fundingSigned = bob2alice.expectMsgType[FundingSigned] + assert(fundingSigned.sigOrPartialSig.isInstanceOf[PartialSignatureWithNonce]) + bob2alice.forward(alice, fundingSigned) awaitCond(alice.stateName == WAIT_FOR_FUNDING_CONFIRMED) val watchConfirmed = alice2blockchain.expectMsgType[WatchFundingConfirmed] val fundingTxId = watchConfirmed.txId @@ -136,6 +140,16 @@ class WaitForFundingSignedStateSpec extends TestKitBaseClass with FixtureAnyFunS listener.expectMsgType[ChannelAborted] } + test("recv FundingSigned with invalid signature (simple taproot channels)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + import f._ + // sending an invalid partial sig + alice ! FundingSigned(ByteVector32.Zeroes, PartialSignatureWithNonce(randomBytes32(), NonceGenerator.signingNonce(randomKey().publicKey, randomKey().publicKey, randomTxId()).publicNonce)) + awaitCond(alice.stateName == CLOSED) + alice2bob.expectMsgType[Error] + aliceOpenReplyTo.expectMsgType[OpenChannelResponse.Rejected] + listener.expectMsgType[ChannelAborted] + } + test("recv CMD_CLOSE") { f => import f._ val sender = TestProbe() diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForDualFundingConfirmedStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForDualFundingConfirmedStateSpec.scala index f144173b41..29495eb81d 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForDualFundingConfirmedStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForDualFundingConfirmedStateSpec.scala @@ -34,7 +34,7 @@ import fr.acinq.eclair.channel.publish.TxPublisher.SetChannelId import fr.acinq.eclair.channel.states.ChannelStateTestsBase.{FakeTxPublisherFactory, PimpTestFSM} import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags} import fr.acinq.eclair.testutils.PimpTestProbe.convert -import fr.acinq.eclair.transactions.Transactions.{ClaimLocalAnchorTx, ClaimRemoteAnchorTx} +import fr.acinq.eclair.transactions.Transactions._ import fr.acinq.eclair.wire.protocol._ import fr.acinq.eclair.{BlockHeight, MilliSatoshiLong, TestConstants, TestKitBaseClass, ToMilliSatoshiConversion} import org.scalatest.funsuite.FixtureAnyFunSuiteLike @@ -870,7 +870,7 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture assert(alice.stateName == WAIT_FOR_DUAL_FUNDING_CONFIRMED) } - private def initiateRbf(f: FixtureParam): Unit = { + private def initiateRbf(f: FixtureParam): TxComplete = { import f._ alice ! CMD_BUMP_FUNDING_FEE(TestProbe().ref, TestConstants.feeratePerKw * 1.1, fundingFeeBudget = 100_000.sat, 0, None) @@ -892,8 +892,9 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture bob2alice.forward(alice) alice2bob.expectMsgType[TxAddOutput] alice2bob.forward(bob) - bob2alice.expectMsgType[TxComplete] + val txCompleteBob = bob2alice.expectMsgType[TxComplete] bob2alice.forward(alice) + txCompleteBob } private def reconnectRbf(f: FixtureParam): (ChannelReestablish, ChannelReestablish) = { @@ -915,7 +916,7 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture (channelReestablishAlice, channelReestablishBob) } - test("recv INPUT_DISCONNECTED (unsigned rbf attempt)", Tag(ChannelStateTestsTags.DualFunding)) { f => + def testDisconnectUnsignedRbfAttempt(f: FixtureParam, commitmentFormat: CommitmentFormat): Unit = { import f._ initiateRbf(f) @@ -923,14 +924,24 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture alice2bob.expectMsgType[CommitSig] // bob doesn't receive alice's commit_sig awaitCond(alice.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED].status.isInstanceOf[DualFundingStatus.RbfWaitingForSigs]) + val fundingTxId = alice.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED].latestFundingTx.sharedTx.txId val rbfTxId = alice.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED].status.asInstanceOf[DualFundingStatus.RbfWaitingForSigs].signingSession.fundingTx.txId assert(bob.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED].status.isInstanceOf[DualFundingStatus.RbfInProgress]) + assert(fundingTxId != rbfTxId) val (channelReestablishAlice, channelReestablishBob) = reconnectRbf(f) assert(channelReestablishAlice.nextFundingTxId_opt.contains(rbfTxId)) assert(channelReestablishAlice.nextLocalCommitmentNumber == 0) assert(channelReestablishBob.nextFundingTxId_opt.isEmpty) assert(channelReestablishBob.nextLocalCommitmentNumber == 1) + commitmentFormat match { + case _: SegwitV0CommitmentFormat => () + case _: TaprootCommitmentFormat => + assert(channelReestablishAlice.currentCommitNonce_opt.nonEmpty) + assert(channelReestablishBob.currentCommitNonce_opt.isEmpty) + assert(channelReestablishAlice.nextCommitNonces.contains(fundingTxId)) + assert(channelReestablishBob.nextCommitNonces.contains(fundingTxId)) + } // Bob detects that Alice stored an old RBF attempt and tells her to abort. bob2alice.expectMsgType[TxAbort] @@ -943,24 +954,48 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture bob2alice.expectNoMessage(100 millis) } - test("recv INPUT_DISCONNECTED (rbf commit_sig received by Alice)", Tag(ChannelStateTestsTags.DualFunding)) { f => + test("recv INPUT_DISCONNECTED (unsigned rbf attempt)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + testDisconnectUnsignedRbfAttempt(f, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) + } + + test("recv INPUT_DISCONNECTED (unsigned rbf attempt, taproot)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + testDisconnectUnsignedRbfAttempt(f, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) + } + + def testDisconnectRbfCommitSigReceivedAlice(f: FixtureParam, commitmentFormat: CommitmentFormat): Unit = { import f._ - initiateRbf(f) - alice2bob.expectMsgType[TxComplete] + val txCompleteBob = initiateRbf(f) + val txCompleteAlice = alice2bob.expectMsgType[TxComplete] alice2bob.forward(bob) bob2alice.expectMsgType[CommitSig] bob2alice.forward(alice) alice2bob.expectMsgType[CommitSig] // Bob doesn't receive Alice's commit_sig awaitCond(alice.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED].status.isInstanceOf[DualFundingStatus.RbfWaitingForSigs]) awaitCond(bob.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED].status.isInstanceOf[DualFundingStatus.RbfWaitingForSigs]) + val fundingTxId = alice.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED].latestFundingTx.sharedTx.txId val rbfTxId = alice.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED].status.asInstanceOf[DualFundingStatus.RbfWaitingForSigs].signingSession.fundingTx.txId + assert(fundingTxId != rbfTxId) val (channelReestablishAlice, channelReestablishBob) = reconnectRbf(f) assert(channelReestablishAlice.nextFundingTxId_opt.contains(rbfTxId)) assert(channelReestablishAlice.nextLocalCommitmentNumber == 1) assert(channelReestablishBob.nextFundingTxId_opt.contains(rbfTxId)) assert(channelReestablishBob.nextLocalCommitmentNumber == 0) + commitmentFormat match { + case _: SegwitV0CommitmentFormat => () + case _: TaprootCommitmentFormat => + assert(channelReestablishAlice.currentCommitNonce_opt.isEmpty) + assert(channelReestablishBob.currentCommitNonce_opt.nonEmpty) + Seq(channelReestablishAlice, channelReestablishBob).foreach(channelReestablish => { + assert(channelReestablish.nextCommitNonces.size == 2) + assert(channelReestablish.nextCommitNonces.values.toSet.size == 2) + assert(channelReestablish.nextCommitNonces.contains(fundingTxId)) + assert(channelReestablish.nextCommitNonces.contains(rbfTxId)) + }) + assert(channelReestablishAlice.nextCommitNonces.get(rbfTxId).contains(txCompleteAlice.nonces_opt.get.nextCommitNonce)) + assert(channelReestablishBob.nextCommitNonces.get(rbfTxId).contains(txCompleteBob.nonces_opt.get.nextCommitNonce)) + } // Alice retransmits commit_sig, and they exchange tx_signatures afterwards. bob2alice.expectNoMessage(100 millis) @@ -979,11 +1014,19 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture awaitCond(bob.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED].status == DualFundingStatus.WaitingForConfirmations) } - test("recv INPUT_DISCONNECTED (rbf commit_sig received by Bob)", Tag(ChannelStateTestsTags.DualFunding)) { f => + test("recv INPUT_DISCONNECTED (rbf commit_sig received by Alice)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + testDisconnectRbfCommitSigReceivedAlice(f, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) + } + + test("recv INPUT_DISCONNECTED (rbf commit_sig received by Alice, taproot)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + testDisconnectRbfCommitSigReceivedAlice(f, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) + } + + def testDisconnectRbfCommitSigReceivedBob(f: FixtureParam, commitmentFormat: CommitmentFormat): Unit = { import f._ - initiateRbf(f) - alice2bob.expectMsgType[TxComplete] + val txCompleteBob = initiateRbf(f) + val txCompleteAlice = alice2bob.expectMsgType[TxComplete] alice2bob.forward(bob) alice2bob.expectMsgType[CommitSig] alice2bob.forward(bob) @@ -991,13 +1034,29 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture bob2alice.expectMsgType[TxSignatures] // Alice doesn't receive Bob's tx_signatures awaitCond(alice.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED].status.isInstanceOf[DualFundingStatus.RbfWaitingForSigs]) awaitCond(bob.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED].status == DualFundingStatus.WaitingForConfirmations) + val fundingTxId = alice.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED].latestFundingTx.sharedTx.txId val rbfTxId = alice.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED].status.asInstanceOf[DualFundingStatus.RbfWaitingForSigs].signingSession.fundingTx.txId + assert(fundingTxId != rbfTxId) val (channelReestablishAlice, channelReestablishBob) = reconnectRbf(f) assert(channelReestablishAlice.nextFundingTxId_opt.contains(rbfTxId)) assert(channelReestablishAlice.nextLocalCommitmentNumber == 0) assert(channelReestablishBob.nextFundingTxId_opt.contains(rbfTxId)) assert(channelReestablishBob.nextLocalCommitmentNumber == 1) + commitmentFormat match { + case _: SegwitV0CommitmentFormat => () + case _: TaprootCommitmentFormat => + assert(channelReestablishAlice.currentCommitNonce_opt.nonEmpty) + assert(channelReestablishBob.currentCommitNonce_opt.isEmpty) + Seq(channelReestablishAlice, channelReestablishBob).foreach(channelReestablish => { + assert(channelReestablish.nextCommitNonces.size == 2) + assert(channelReestablish.nextCommitNonces.values.toSet.size == 2) + assert(channelReestablish.nextCommitNonces.contains(fundingTxId)) + assert(channelReestablish.nextCommitNonces.contains(rbfTxId)) + }) + assert(channelReestablishAlice.nextCommitNonces.get(rbfTxId).contains(txCompleteAlice.nonces_opt.get.nextCommitNonce)) + assert(channelReestablishBob.nextCommitNonces.get(rbfTxId).contains(txCompleteBob.nonces_opt.get.nextCommitNonce)) + } // Bob retransmits commit_sig and tx_signatures, then Alice sends her tx_signatures. bob2alice.expectMsgType[CommitSig] @@ -1016,7 +1075,15 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture awaitCond(bob.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED].status == DualFundingStatus.WaitingForConfirmations) } - test("recv INPUT_DISCONNECTED (rbf commit_sig received)", Tag(ChannelStateTestsTags.DualFunding)) { f => + test("recv INPUT_DISCONNECTED (rbf commit_sig received by Bob)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + testDisconnectRbfCommitSigReceivedBob(f, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) + } + + test("recv INPUT_DISCONNECTED (rbf commit_sig received by Bob, taproot)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + testDisconnectRbfCommitSigReceivedBob(f, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) + } + + test("recv INPUT_DISCONNECTED (rbf commit_sig received by Bob, taproot, missing current commit nonce)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => import f._ initiateRbf(f) @@ -1024,18 +1091,67 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture alice2bob.forward(bob) alice2bob.expectMsgType[CommitSig] alice2bob.forward(bob) + bob2alice.expectMsgType[CommitSig] // Alice doesn't receive Bob's commit_sig + bob2alice.expectMsgType[TxSignatures] // Alice doesn't receive Bob's tx_signatures + awaitCond(alice.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED].status.isInstanceOf[DualFundingStatus.RbfWaitingForSigs]) + awaitCond(bob.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED].status == DualFundingStatus.WaitingForConfirmations) + + alice ! INPUT_DISCONNECTED + awaitCond(alice.stateName == OFFLINE) + bob ! INPUT_DISCONNECTED + awaitCond(bob.stateName == OFFLINE) + + // Alice is buggy and doesn't include her current commit nonce in channel_reestablish. + val aliceInit = Init(alice.underlyingActor.nodeParams.features.initFeatures()) + val bobInit = Init(bob.underlyingActor.nodeParams.features.initFeatures()) + alice ! INPUT_RECONNECTED(bob, aliceInit, bobInit) + bob ! INPUT_RECONNECTED(alice, bobInit, aliceInit) + val channelReestablishAlice = alice2bob.expectMsgType[ChannelReestablish] + assert(channelReestablishAlice.nextFundingTxId_opt.nonEmpty) + assert(channelReestablishAlice.nextLocalCommitmentNumber == 0) + assert(channelReestablishAlice.currentCommitNonce_opt.nonEmpty) + bob2alice.expectMsgType[ChannelReestablish] + alice2bob.forward(bob, channelReestablishAlice.copy(tlvStream = TlvStream(channelReestablishAlice.tlvStream.records.filterNot(_.isInstanceOf[ChannelReestablishTlv.CurrentCommitNonceTlv])))) + bob2alice.expectMsgType[Error] + awaitCond(bob.stateName == CLOSING) + } + + def testDisconnectRbfCommitSigReceived(f: FixtureParam, commitmentFormat: CommitmentFormat): Unit = { + import f._ + + val txCompleteBob = initiateRbf(f) + val txCompleteAlice = alice2bob.expectMsgType[TxComplete] + alice2bob.forward(bob) + alice2bob.expectMsgType[CommitSig] + alice2bob.forward(bob) bob2alice.expectMsgType[CommitSig] bob2alice.forward(alice) bob2alice.expectMsgType[TxSignatures] // Alice doesn't receive Bob's tx_signatures awaitCond(alice.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED].status.isInstanceOf[DualFundingStatus.RbfWaitingForSigs]) awaitCond(bob.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED].status == DualFundingStatus.WaitingForConfirmations) + val fundingTxId = alice.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED].latestFundingTx.sharedTx.txId val rbfTx = alice.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED].status.asInstanceOf[DualFundingStatus.RbfWaitingForSigs].signingSession.fundingTx + assert(fundingTxId != rbfTx.txId) val (channelReestablishAlice, channelReestablishBob) = reconnectRbf(f) assert(channelReestablishAlice.nextFundingTxId_opt.contains(rbfTx.txId)) + assert(channelReestablishAlice.currentCommitNonce_opt.isEmpty) assert(channelReestablishAlice.nextLocalCommitmentNumber == 1) assert(channelReestablishBob.nextFundingTxId_opt.contains(rbfTx.txId)) + assert(channelReestablishBob.currentCommitNonce_opt.isEmpty) assert(channelReestablishBob.nextLocalCommitmentNumber == 1) + commitmentFormat match { + case _: SegwitV0CommitmentFormat => () + case _: TaprootCommitmentFormat => + Seq(channelReestablishAlice, channelReestablishBob).foreach(channelReestablish => { + assert(channelReestablish.nextCommitNonces.size == 2) + assert(channelReestablish.nextCommitNonces.values.toSet.size == 2) + assert(channelReestablish.nextCommitNonces.contains(fundingTxId)) + assert(channelReestablish.nextCommitNonces.contains(rbfTx.txId)) + }) + assert(channelReestablishAlice.nextCommitNonces.get(rbfTx.txId).contains(txCompleteAlice.nonces_opt.get.nextCommitNonce)) + assert(channelReestablishBob.nextCommitNonces.get(rbfTx.txId).contains(txCompleteBob.nonces_opt.get.nextCommitNonce)) + } // Alice and Bob exchange tx_signatures and complete the RBF attempt. bob2alice.expectMsgType[TxSignatures] @@ -1051,10 +1167,17 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture awaitCond(bob.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED].status == DualFundingStatus.WaitingForConfirmations) } - test("recv INPUT_DISCONNECTED (rbf tx_signatures partially received)", Tag(ChannelStateTestsTags.DualFunding)) { f => + test("recv INPUT_DISCONNECTED (rbf commit_sig received)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + testDisconnectRbfCommitSigReceived(f, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) + } + + test("recv INPUT_DISCONNECTED (rbf commit_sig received, taproot)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + testDisconnectRbfCommitSigReceived(f, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) + } + + test("recv INPUT_DISCONNECTED (rbf commit_sig received, taproot, missing next commit nonce)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => import f._ - val currentFundingTxId = alice.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED].latestFundingTx.sharedTx.txId initiateRbf(f) alice2bob.expectMsgType[TxComplete] alice2bob.forward(bob) @@ -1062,6 +1185,49 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture alice2bob.forward(bob) bob2alice.expectMsgType[CommitSig] bob2alice.forward(alice) + bob2alice.expectMsgType[TxSignatures] // Alice doesn't receive Bob's tx_signatures + awaitCond(alice.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED].status.isInstanceOf[DualFundingStatus.RbfWaitingForSigs]) + awaitCond(bob.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED].status == DualFundingStatus.WaitingForConfirmations) + val fundingTxId = alice.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED].latestFundingTx.sharedTx.txId + val rbfTxId = alice.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED].status.asInstanceOf[DualFundingStatus.RbfWaitingForSigs].signingSession.fundingTx.txId + assert(fundingTxId != rbfTxId) + + alice ! INPUT_DISCONNECTED + awaitCond(alice.stateName == OFFLINE) + bob ! INPUT_DISCONNECTED + awaitCond(bob.stateName == OFFLINE) + + val aliceInit = Init(alice.underlyingActor.nodeParams.features.initFeatures()) + val bobInit = Init(bob.underlyingActor.nodeParams.features.initFeatures()) + alice ! INPUT_RECONNECTED(bob, aliceInit, bobInit) + bob ! INPUT_RECONNECTED(alice, bobInit, aliceInit) + // Alice is buggy and doesn't include her next commit nonce for the initial funding tx. + val channelReestablishAlice = alice2bob.expectMsgType[ChannelReestablish] + val aliceNonces = ChannelReestablishTlv.NextLocalNoncesTlv((channelReestablishAlice.nextCommitNonces - fundingTxId).toSeq) + val channelReestablishAlice1 = channelReestablishAlice.copy(tlvStream = TlvStream(channelReestablishAlice.tlvStream.records.filterNot(_.isInstanceOf[ChannelReestablishTlv.NextLocalNoncesTlv]) + aliceNonces)) + // Bob is buggy and doesn't include his next commit nonce for the RBF tx. + val channelReestablishBob = bob2alice.expectMsgType[ChannelReestablish] + val bobNonces = ChannelReestablishTlv.NextLocalNoncesTlv((channelReestablishBob.nextCommitNonces - rbfTxId).toSeq) + val channelReestablishBob1 = channelReestablishBob.copy(tlvStream = TlvStream(channelReestablishBob.tlvStream.records.filterNot(_.isInstanceOf[ChannelReestablishTlv.NextLocalNoncesTlv]) + bobNonces)) + alice2bob.forward(bob, channelReestablishAlice1) + assert(bob2alice.expectMsgType[Error].toAscii == MissingCommitNonce(channelReestablishBob.channelId, fundingTxId, commitmentNumber = 1).getMessage) + awaitCond(bob.stateName == CLOSING) + bob2alice.forward(alice, channelReestablishBob1) + assert(alice2bob.expectMsgType[Error].toAscii == MissingCommitNonce(channelReestablishAlice.channelId, rbfTxId, commitmentNumber = 1).getMessage) + awaitCond(alice.stateName == CLOSING) + } + + def testDisconnectTxSigsPartiallyReceived(f: FixtureParam, commitmentFormat: CommitmentFormat): Unit = { + import f._ + + val currentFundingTxId = alice.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED].latestFundingTx.sharedTx.txId + val txCompleteBob = initiateRbf(f) + val txCompleteAlice = alice2bob.expectMsgType[TxComplete] + alice2bob.forward(bob) + alice2bob.expectMsgType[CommitSig] + alice2bob.forward(bob) + bob2alice.expectMsgType[CommitSig] + bob2alice.forward(alice) bob2alice.expectMsgType[TxSignatures] bob2alice.forward(alice) alice2bob.expectMsgType[TxSignatures] // Bob doesn't receive Alice's tx_signatures @@ -1072,9 +1238,23 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture val (channelReestablishAlice, channelReestablishBob) = reconnectRbf(f) assert(channelReestablishAlice.nextFundingTxId_opt.isEmpty) + assert(channelReestablishAlice.currentCommitNonce_opt.isEmpty) assert(channelReestablishAlice.nextLocalCommitmentNumber == 1) assert(channelReestablishBob.nextFundingTxId_opt.contains(rbfTxId)) + assert(channelReestablishBob.currentCommitNonce_opt.isEmpty) assert(channelReestablishBob.nextLocalCommitmentNumber == 1) + commitmentFormat match { + case _: SegwitV0CommitmentFormat => () + case _: TaprootCommitmentFormat => + Seq(channelReestablishAlice, channelReestablishBob).foreach(channelReestablish => { + assert(channelReestablish.nextCommitNonces.size == 2) + assert(channelReestablish.nextCommitNonces.values.toSet.size == 2) + assert(channelReestablish.nextCommitNonces.contains(currentFundingTxId)) + assert(channelReestablish.nextCommitNonces.contains(rbfTxId)) + }) + assert(channelReestablishAlice.nextCommitNonces.get(rbfTxId).contains(txCompleteAlice.nonces_opt.get.nextCommitNonce)) + assert(channelReestablishBob.nextCommitNonces.get(rbfTxId).contains(txCompleteBob.nonces_opt.get.nextCommitNonce)) + } // Alice and Bob exchange signatures and complete the RBF attempt. bob2alice.expectNoMessage(100 millis) @@ -1089,6 +1269,14 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture awaitCond(bob.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED].status == DualFundingStatus.WaitingForConfirmations) } + test("recv INPUT_DISCONNECTED (rbf tx_signatures partially received)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + testDisconnectTxSigsPartiallyReceived(f, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) + } + + test("recv INPUT_DISCONNECTED (rbf tx_signatures partially received, taproot)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + testDisconnectTxSigsPartiallyReceived(f, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) + } + test("recv Error", Tag(ChannelStateTestsTags.DualFunding)) { f => import f._ val tx = alice.signCommitTx() diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala index cad97340e6..4dc750b41e 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala @@ -22,7 +22,7 @@ import akka.testkit.{TestFSMRef, TestProbe} import com.softwaremill.quicklens.ModifyPimp import fr.acinq.bitcoin.ScriptFlags import fr.acinq.bitcoin.scalacompat.NumericSatoshi.abs -import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, SatoshiLong, Transaction} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Crypto, Satoshi, SatoshiLong, Transaction} import fr.acinq.eclair._ import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.fee.FeeratePerKw @@ -60,11 +60,8 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik implicit val log: akka.event.LoggingAdapter = akka.event.NoLogging - val extraTags: Set[String] = Set.empty - val spliceChannelType_opt: Option[ChannelType] = None - override def withFixture(test: OneArgTest): Outcome = { - val tags = test.tags + ChannelStateTestsTags.DualFunding ++ extraTags + val tags = test.tags + ChannelStateTestsTags.DualFunding val setup = init(tags = tags) import setup._ reachNormal(setup, tags) @@ -77,9 +74,9 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik private val defaultSpliceOutScriptPubKey = hex"0020aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - private def initiateSpliceWithoutSigs(s: TestFSMRef[ChannelState, ChannelData, Channel], r: TestFSMRef[ChannelState, ChannelData, Channel], s2r: TestProbe, r2s: TestProbe, spliceIn_opt: Option[SpliceIn], spliceOut_opt: Option[SpliceOut], sendTxComplete: Boolean): TestProbe = { + private def initiateSpliceWithoutSigs(s: TestFSMRef[ChannelState, ChannelData, Channel], r: TestFSMRef[ChannelState, ChannelData, Channel], s2r: TestProbe, r2s: TestProbe, spliceIn_opt: Option[SpliceIn], spliceOut_opt: Option[SpliceOut], channelType_opt: Option[ChannelType], sendTxComplete: Boolean): TestProbe = { val sender = TestProbe() - val cmd = CMD_SPLICE(sender.ref, spliceIn_opt, spliceOut_opt, None, this.spliceChannelType_opt) + val cmd = CMD_SPLICE(sender.ref, spliceIn_opt, spliceOut_opt, None, channelType_opt) s ! cmd exchangeStfu(s, r, s2r, r2s) s2r.expectMsgType[SpliceInit] @@ -118,7 +115,9 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik sender } - private def initiateSpliceWithoutSigs(f: FixtureParam, spliceIn_opt: Option[SpliceIn] = None, spliceOut_opt: Option[SpliceOut] = None, sendTxComplete: Boolean = true): TestProbe = initiateSpliceWithoutSigs(f.alice, f.bob, f.alice2bob, f.bob2alice, spliceIn_opt, spliceOut_opt, sendTxComplete) + private def initiateSpliceWithoutSigs(f: FixtureParam, spliceIn_opt: Option[SpliceIn] = None, spliceOut_opt: Option[SpliceOut] = None, channelType_opt: Option[ChannelType] = None, sendTxComplete: Boolean = true): TestProbe = { + initiateSpliceWithoutSigs(f.alice, f.bob, f.alice2bob, f.bob2alice, spliceIn_opt, spliceOut_opt, channelType_opt, sendTxComplete) + } private def initiateRbfWithoutSigs(s: TestFSMRef[ChannelState, ChannelData, Channel], r: TestFSMRef[ChannelState, ChannelData, Channel], s2r: TestProbe, r2s: TestProbe, feerate: FeeratePerKw, sInputsCount: Int, sOutputsCount: Int, rInputsCount: Int, rOutputsCount: Int): TestProbe = { val sender = TestProbe() @@ -218,12 +217,14 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik private def exchangeSpliceSigs(f: FixtureParam, sender: TestProbe): Transaction = exchangeSpliceSigs(f.alice, f.bob, f.alice2bob, f.bob2alice, sender) - private def initiateSplice(s: TestFSMRef[ChannelState, ChannelData, Channel], r: TestFSMRef[ChannelState, ChannelData, Channel], s2r: TestProbe, r2s: TestProbe, spliceIn_opt: Option[SpliceIn], spliceOut_opt: Option[SpliceOut]): Transaction = { - val sender = initiateSpliceWithoutSigs(s, r, s2r, r2s, spliceIn_opt, spliceOut_opt, sendTxComplete = true) + private def initiateSplice(s: TestFSMRef[ChannelState, ChannelData, Channel], r: TestFSMRef[ChannelState, ChannelData, Channel], s2r: TestProbe, r2s: TestProbe, spliceIn_opt: Option[SpliceIn], spliceOut_opt: Option[SpliceOut], channelType_opt: Option[ChannelType]): Transaction = { + val sender = initiateSpliceWithoutSigs(s, r, s2r, r2s, spliceIn_opt, spliceOut_opt, channelType_opt, sendTxComplete = true) exchangeSpliceSigs(s, r, s2r, r2s, sender) } - private def initiateSplice(f: FixtureParam, spliceIn_opt: Option[SpliceIn] = None, spliceOut_opt: Option[SpliceOut] = None): Transaction = initiateSplice(f.alice, f.bob, f.alice2bob, f.bob2alice, spliceIn_opt, spliceOut_opt) + private def initiateSplice(f: FixtureParam, spliceIn_opt: Option[SpliceIn] = None, spliceOut_opt: Option[SpliceOut] = None, channelType_opt: Option[ChannelType] = None): Transaction = { + initiateSplice(f.alice, f.bob, f.alice2bob, f.bob2alice, spliceIn_opt, spliceOut_opt, channelType_opt) + } private def initiateRbf(f: FixtureParam, feerate: FeeratePerKw, sInputsCount: Int, sOutputsCount: Int): Transaction = { val sender = initiateRbfWithoutSigs(f, feerate, sInputsCount, sOutputsCount) @@ -357,7 +358,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik assert(finalState.commitments.latest.localCommit.spec.toRemote == 700_000_000.msat - settledHtlcs) } - test("recv CMD_SPLICE (splice-in)", Tag(ChannelStateTestsTags.OptionSimpleTaprootPhoenix)) { f => + test("recv CMD_SPLICE (splice-in)") { f => import f._ val initialState = alice.stateData.asInstanceOf[DATA_NORMAL] @@ -412,7 +413,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik val sender = TestProbe() val fundingRequest = LiquidityAds.RequestFunding(400_000 sat, TestConstants.defaultLiquidityRates.fundingRates.head, LiquidityAds.PaymentDetails.FromChannelBalance) - val cmd = CMD_SPLICE(sender.ref, Some(SpliceIn(500_000 sat)), None, Some(fundingRequest), this.spliceChannelType_opt) + val cmd = CMD_SPLICE(sender.ref, Some(SpliceIn(500_000 sat)), None, Some(fundingRequest), None) alice ! cmd exchangeStfu(alice, bob, alice2bob, bob2alice) @@ -461,7 +462,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik val sender = TestProbe() val fundingRequest = LiquidityAds.RequestFunding(400_000 sat, TestConstants.defaultLiquidityRates.fundingRates.head, LiquidityAds.PaymentDetails.FromChannelBalance) - val cmd = CMD_SPLICE(sender.ref, Some(SpliceIn(500_000 sat)), None, Some(fundingRequest), this.spliceChannelType_opt) + val cmd = CMD_SPLICE(sender.ref, Some(SpliceIn(500_000 sat)), None, Some(fundingRequest), None) alice ! cmd exchangeStfu(alice, bob, alice2bob, bob2alice) @@ -488,7 +489,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik val sender = TestProbe() val fundingRequest = LiquidityAds.RequestFunding(5_000 sat, TestConstants.defaultLiquidityRates.fundingRates.head, LiquidityAds.PaymentDetails.FromChannelBalance) - val cmd = CMD_SPLICE(sender.ref, Some(SpliceIn(500_000 sat)), None, Some(fundingRequest), this.spliceChannelType_opt) + val cmd = CMD_SPLICE(sender.ref, Some(SpliceIn(500_000 sat)), None, Some(fundingRequest), None) alice ! cmd exchangeStfu(alice, bob, alice2bob, bob2alice) @@ -505,7 +506,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik val sender = TestProbe() val fundingRequest = LiquidityAds.RequestFunding(100_000 sat, LiquidityAds.FundingRate(10_000 sat, 200_000 sat, 0, 0, 0 sat, 0 sat), LiquidityAds.PaymentDetails.FromChannelBalance) - val cmd = CMD_SPLICE(sender.ref, Some(SpliceIn(500_000 sat)), None, Some(fundingRequest), this.spliceChannelType_opt) + val cmd = CMD_SPLICE(sender.ref, Some(SpliceIn(500_000 sat)), None, Some(fundingRequest), None) alice ! cmd exchangeStfu(alice, bob, alice2bob, bob2alice) @@ -524,7 +525,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik // Alice requests a lot of funding, but she doesn't have enough balance to pay the corresponding fee. assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localCommit.spec.toLocal == 800_000_000.msat) val fundingRequest = LiquidityAds.RequestFunding(5_000_000 sat, TestConstants.defaultLiquidityRates.fundingRates.head, LiquidityAds.PaymentDetails.FromChannelBalance) - val cmd = CMD_SPLICE(sender.ref, None, Some(SpliceOut(750_000 sat, defaultSpliceOutScriptPubKey)), Some(fundingRequest), this.spliceChannelType_opt) + val cmd = CMD_SPLICE(sender.ref, None, Some(SpliceOut(750_000 sat, defaultSpliceOutScriptPubKey)), Some(fundingRequest), None) alice ! cmd exchangeStfu(alice, bob, alice2bob, bob2alice) @@ -612,7 +613,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik assert(commitFees < 15_000.sat) val sender = TestProbe() - val cmd = CMD_SPLICE(sender.ref, spliceIn_opt = None, Some(SpliceOut(760_000 sat, defaultSpliceOutScriptPubKey)), requestFunding_opt = None, this.spliceChannelType_opt) + val cmd = CMD_SPLICE(sender.ref, spliceIn_opt = None, Some(SpliceOut(760_000 sat, defaultSpliceOutScriptPubKey)), requestFunding_opt = None, channelType_opt = None) alice ! cmd exchangeStfu(f) sender.expectMsgType[RES_FAILURE[_, _]] @@ -629,13 +630,10 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik assert(commitment.localCommit.spec.toLocal == 650_000_000.msat) assert(commitment.localChannelReserve == 15_000.sat) val commitFees = Transactions.commitTxTotalCost(commitment.remoteCommitParams.dustLimit, commitment.remoteCommit.spec, commitment.commitmentFormat) - commitment.commitmentFormat match { - case _: SimpleTaprootChannelCommitmentFormat | _: AnchorOutputsCommitmentFormat => assert(commitFees > 7_000.sat) - case DefaultCommitmentFormat => assert(commitFees > 20_000.sat) - } + assert(commitFees > 20_000.sat) val sender = TestProbe() - val cmd = CMD_SPLICE(sender.ref, spliceIn_opt = None, Some(SpliceOut(630_000 sat, defaultSpliceOutScriptPubKey)), requestFunding_opt = None, this.spliceChannelType_opt) + val cmd = CMD_SPLICE(sender.ref, spliceIn_opt = None, Some(SpliceOut(630_000 sat, defaultSpliceOutScriptPubKey)), requestFunding_opt = None, channelType_opt = None) alice ! cmd exchangeStfu(f) sender.expectMsgType[RES_FAILURE[_, _]] @@ -645,7 +643,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik import f._ val sender = TestProbe() - val cmd = CMD_SPLICE(sender.ref, spliceIn_opt = Some(SpliceIn(500_000 sat)), spliceOut_opt = None, requestFunding_opt = None, this.spliceChannelType_opt) + val cmd = CMD_SPLICE(sender.ref, spliceIn_opt = Some(SpliceIn(500_000 sat)), spliceOut_opt = None, requestFunding_opt = None, channelType_opt = None) alice ! cmd exchangeStfu(f) // we tweak the feerate @@ -666,7 +664,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik val sender = TestProbe() val bobBalance = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localCommit.spec.toLocal - alice ! CMD_SPLICE(sender.ref, spliceIn_opt = Some(SpliceIn(100_000 sat)), spliceOut_opt = None, requestFunding_opt = None, this.spliceChannelType_opt) + alice ! CMD_SPLICE(sender.ref, spliceIn_opt = Some(SpliceIn(100_000 sat)), spliceOut_opt = None, requestFunding_opt = None, channelType_opt = None) exchangeStfu(f) val spliceInit = alice2bob.expectMsgType[SpliceInit] alice2bob.forward(bob, spliceInit) @@ -691,7 +689,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik crossSign(bob, alice, bob2alice, alice2bob) // Bob makes a large splice: Alice doesn't meet the new reserve requirements, but she met the previous one, so we allow this. - initiateSplice(bob, alice, bob2alice, alice2bob, spliceIn_opt = Some(SpliceIn(4_000_000 sat)), spliceOut_opt = None) + initiateSplice(bob, alice, bob2alice, alice2bob, spliceIn_opt = Some(SpliceIn(4_000_000 sat)), spliceOut_opt = None, channelType_opt = None) val postSpliceState = alice.stateData.asInstanceOf[DATA_NORMAL] assert(postSpliceState.commitments.latest.localCommit.spec.toLocal < postSpliceState.commitments.latest.localChannelReserve) @@ -719,7 +717,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik initiateRbf(f, FeeratePerKw(15_000 sat), sInputsCount = 2, sOutputsCount = 1) val probe = TestProbe() - alice ! CMD_SPLICE(probe.ref, Some(SpliceIn(250_000 sat)), None, None, this.spliceChannelType_opt) + alice ! CMD_SPLICE(probe.ref, Some(SpliceIn(250_000 sat)), None, None, None) assert(probe.expectMsgType[RES_FAILURE[_, ChannelException]].t.isInstanceOf[InvalidSpliceWithUnconfirmedTx]) bob2alice.forward(alice, Stfu(alice.stateData.channelId, initiator = true)) @@ -737,7 +735,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik // We allow initiating such splice... val probe = TestProbe() - alice ! CMD_SPLICE(probe.ref, Some(SpliceIn(250_000 sat)), None, None, this.spliceChannelType_opt) + alice ! CMD_SPLICE(probe.ref, Some(SpliceIn(250_000 sat)), None, None, None) alice2bob.expectMsgType[Stfu] alice2bob.forward(bob) bob2alice.expectMsgType[Stfu] @@ -755,6 +753,16 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik resolveHtlcs(f, htlcs) } + test("recv CMD_SPLICE (upgrade channel to taproot)", Tag(ChannelStateTestsTags.AnchorOutputs)) { f => + import f._ + + val htlcs = setupHtlcs(f) + initiateSplice(f, spliceIn_opt = Some(SpliceIn(400_000 sat)), channelType_opt = Some(ChannelTypes.SimpleTaprootChannelsPhoenix())) + assert(alice.commitments.active.head.commitmentFormat == PhoenixSimpleTaprootChannelCommitmentFormat) + assert(alice.commitments.active.last.commitmentFormat == UnsafeLegacyAnchorOutputsCommitmentFormat) + resolveHtlcs(f, htlcs) + } + test("recv CMD_BUMP_FUNDING_FEE (splice-in + splice-out)") { f => import f._ @@ -803,7 +811,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik } // We can keep doing more splice transactions now that one of the previous transactions confirmed. - initiateSplice(bob, alice, bob2alice, alice2bob, Some(SpliceIn(100_000 sat)), None) + initiateSplice(bob, alice, bob2alice, alice2bob, Some(SpliceIn(100_000 sat)), None, None) } test("recv CMD_BUMP_FUNDING_FEE (splice-in + splice-out from non-initiator)") { f => @@ -814,7 +822,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik confirmSpliceTx(f, spliceTx1) // Bob initiates a second splice that spends the first splice. - val spliceTx2 = initiateSplice(bob, alice, bob2alice, alice2bob, spliceIn_opt = Some(SpliceIn(50_000 sat)), spliceOut_opt = Some(SpliceOut(25_000 sat, defaultSpliceOutScriptPubKey))) + val spliceTx2 = initiateSplice(bob, alice, bob2alice, alice2bob, spliceIn_opt = Some(SpliceIn(50_000 sat)), spliceOut_opt = Some(SpliceOut(25_000 sat, defaultSpliceOutScriptPubKey)), channelType_opt = None) assert(spliceTx2.txIn.exists(_.outPoint.txid == spliceTx1.txid)) // Alice cannot RBF her first splice, so she RBFs Bob's splice instead. @@ -830,7 +838,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik // Alice initiates a splice-in with a liquidity purchase. val sender = TestProbe() val fundingRequest = LiquidityAds.RequestFunding(400_000 sat, TestConstants.defaultLiquidityRates.fundingRates.head, LiquidityAds.PaymentDetails.FromChannelBalance) - alice ! CMD_SPLICE(sender.ref, Some(SpliceIn(500_000 sat)), None, Some(fundingRequest), this.spliceChannelType_opt) + alice ! CMD_SPLICE(sender.ref, Some(SpliceIn(500_000 sat)), None, Some(fundingRequest), None) exchangeStfu(alice, bob, alice2bob, bob2alice) inside(alice2bob.expectMsgType[SpliceInit]) { msg => assert(msg.fundingContribution == 500_000.sat) @@ -1001,7 +1009,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik import f._ val sender = TestProbe() - alice ! CMD_SPLICE(sender.ref, spliceIn_opt = None, spliceOut_opt = Some(SpliceOut(50_000 sat, defaultSpliceOutScriptPubKey)), requestFunding_opt = None, this.spliceChannelType_opt) + alice ! CMD_SPLICE(sender.ref, spliceIn_opt = None, spliceOut_opt = Some(SpliceOut(50_000 sat, defaultSpliceOutScriptPubKey)), requestFunding_opt = None, channelType_opt = None) exchangeStfu(f) alice2bob.expectMsgType[SpliceInit] alice2bob.forward(bob) @@ -1026,7 +1034,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik import f._ val sender = TestProbe() - alice ! CMD_SPLICE(sender.ref, spliceIn_opt = None, spliceOut_opt = Some(SpliceOut(50_000 sat, defaultSpliceOutScriptPubKey)), requestFunding_opt = None, this.spliceChannelType_opt) + alice ! CMD_SPLICE(sender.ref, spliceIn_opt = None, spliceOut_opt = Some(SpliceOut(50_000 sat, defaultSpliceOutScriptPubKey)), requestFunding_opt = None, channelType_opt = None) exchangeStfu(f) alice2bob.expectMsgType[SpliceInit] alice2bob.forward(bob) @@ -1061,10 +1069,8 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik test("recv TxAbort (after CommitSig)") { f => import f._ - assume(!this.extraTags.contains(ChannelStateTestsTags.OptionSimpleTaprootPhoenix) && !this.extraTags.contains(ChannelStateTestsTags.OptionSimpleTaproot) && this.spliceChannelType_opt.isEmpty) - val sender = TestProbe() - alice ! CMD_SPLICE(sender.ref, spliceIn_opt = Some(SpliceIn(50_000 sat)), spliceOut_opt = None, requestFunding_opt = None, this.spliceChannelType_opt) + alice ! CMD_SPLICE(sender.ref, spliceIn_opt = Some(SpliceIn(50_000 sat)), spliceOut_opt = None, requestFunding_opt = None, channelType_opt = None) exchangeStfu(f) alice2bob.expectMsgType[SpliceInit] alice2bob.forward(bob) @@ -1526,7 +1532,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik awaitAssert(assert(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.all.size == 1)) } - test("recv CMD_ADD_HTLC with multiple commitments", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + test("recv CMD_ADD_HTLC with multiple commitments") { f => import f._ initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat))) val sender = TestProbe() @@ -1549,15 +1555,43 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik awaitCond(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.active.forall(_.localCommit.spec.htlcs.size == 1)) } - test("recv CMD_ADD_HTLC with multiple commitments and reconnect") { f => + test("recv CMD_ADD_HTLC with multiple commitments (missing nonces)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => import f._ - initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat))) + val spliceTx = initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat))) + bob2blockchain.expectWatchFundingConfirmed(spliceTx.txid) val sender = TestProbe() alice ! CMD_ADD_HTLC(sender.ref, 500_000 msat, randomBytes32(), CltvExpiryDelta(144).toCltvExpiry(currentBlockHeight), TestConstants.emptyOnionPacket, None, Reputation.Score.max, None, localOrigin(sender.ref)) sender.expectMsgType[RES_SUCCESS[CMD_ADD_HTLC]] alice2bob.expectMsgType[UpdateAddHtlc] alice2bob.forward(bob) alice ! CMD_SIGN() + val sigsA = alice2bob.expectMsgType[CommitSigBatch] + assert(sigsA.batchSize == 2) + alice2bob.forward(bob, sigsA) + bob2alice.expectMsgType[RevokeAndAck] + bob2alice.forward(alice) + val sigsB = bob2alice.expectMsgType[CommitSigBatch] + assert(sigsB.batchSize == 2) + bob2alice.forward(alice, sigsB) + val revA = alice2bob.expectMsgType[RevokeAndAck] + assert(revA.nextCommitNonces.size == 2) + val missingNonce = RevokeAndAckTlv.NextLocalNoncesTlv(revA.nextCommitNonces.toSeq.take(1)) + alice2bob.forward(bob, revA.copy(tlvStream = TlvStream(revA.tlvStream.records.filterNot(_.isInstanceOf[RevokeAndAckTlv.NextLocalNoncesTlv]) + missingNonce))) + bob2alice.expectMsgType[Error] + val commitTx = bob2blockchain.expectFinalTxPublished("commit-tx").tx + Transaction.correctlySpends(commitTx, Seq(spliceTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + } + + test("recv CMD_ADD_HTLC with multiple commitments and reconnect", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + import f._ + initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat))) + val sender = TestProbe() + val preimage = randomBytes32() + alice ! CMD_ADD_HTLC(sender.ref, 500_000 msat, Crypto.sha256(preimage), CltvExpiryDelta(144).toCltvExpiry(currentBlockHeight), TestConstants.emptyOnionPacket, None, Reputation.Score.max, None, localOrigin(sender.ref)) + sender.expectMsgType[RES_SUCCESS[CMD_ADD_HTLC]] + val add = alice2bob.expectMsgType[UpdateAddHtlc] + alice2bob.forward(bob) + alice ! CMD_SIGN() assert(alice2bob.expectMsgType[CommitSigBatch].batchSize == 2) // Bob disconnects before receiving Alice's commit_sig. disconnect(f) @@ -1567,21 +1601,23 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik val sigsA = alice2bob.expectMsgType[CommitSigBatch] assert(sigsA.batchSize == 2) alice2bob.forward(bob, sigsA) - bob2alice.expectMsgType[RevokeAndAck] + assert(bob2alice.expectMsgType[RevokeAndAck].nextCommitNonces.size == 2) bob2alice.forward(alice) val sigsB = bob2alice.expectMsgType[CommitSigBatch] assert(sigsB.batchSize == 2) bob2alice.forward(alice, sigsB) - alice2bob.expectMsgType[RevokeAndAck] + assert(alice2bob.expectMsgType[RevokeAndAck].nextCommitNonces.size == 2) alice2bob.forward(bob) awaitCond(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.active.forall(_.localCommit.spec.htlcs.size == 1)) awaitCond(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.active.forall(_.localCommit.spec.htlcs.size == 1)) + fulfillHtlc(add.id, preimage, bob, alice, bob2alice, alice2bob) + crossSign(bob, alice, bob2alice, alice2bob) } test("recv CMD_ADD_HTLC while a splice is requested") { f => import f._ val sender = TestProbe() - val cmd = CMD_SPLICE(sender.ref, spliceIn_opt = Some(SpliceIn(500_000 sat, pushAmount = 0 msat)), spliceOut_opt = None, requestFunding_opt = None, this.spliceChannelType_opt) + val cmd = CMD_SPLICE(sender.ref, spliceIn_opt = Some(SpliceIn(500_000 sat, pushAmount = 0 msat)), spliceOut_opt = None, requestFunding_opt = None, channelType_opt = None) alice ! cmd exchangeStfu(f) alice2bob.expectMsgType[SpliceInit] @@ -1593,7 +1629,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik test("recv CMD_ADD_HTLC while a splice is in progress") { f => import f._ val sender = TestProbe() - val cmd = CMD_SPLICE(sender.ref, spliceIn_opt = Some(SpliceIn(500_000 sat, pushAmount = 0 msat)), spliceOut_opt = None, requestFunding_opt = None, this.spliceChannelType_opt) + val cmd = CMD_SPLICE(sender.ref, spliceIn_opt = Some(SpliceIn(500_000 sat, pushAmount = 0 msat)), spliceOut_opt = None, requestFunding_opt = None, channelType_opt = None) alice ! cmd exchangeStfu(f) alice2bob.expectMsgType[SpliceInit] @@ -1609,7 +1645,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik test("recv UpdateAddHtlc while a splice is in progress") { f => import f._ val sender = TestProbe() - val cmd = CMD_SPLICE(sender.ref, spliceIn_opt = Some(SpliceIn(500_000 sat, pushAmount = 0 msat)), spliceOut_opt = None, requestFunding_opt = None, this.spliceChannelType_opt) + val cmd = CMD_SPLICE(sender.ref, spliceIn_opt = Some(SpliceIn(500_000 sat, pushAmount = 0 msat)), spliceOut_opt = None, requestFunding_opt = None, channelType_opt = None) alice ! cmd exchangeStfu(f) alice2bob.expectMsgType[SpliceInit] @@ -1779,7 +1815,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik import f._ val sender = TestProbe() - val cmd = CMD_SPLICE(sender.ref, spliceIn_opt = Some(SpliceIn(500_000 sat, pushAmount = 0 msat)), spliceOut_opt = None, requestFunding_opt = None, this.spliceChannelType_opt) + val cmd = CMD_SPLICE(sender.ref, spliceIn_opt = Some(SpliceIn(500_000 sat, pushAmount = 0 msat)), spliceOut_opt = None, requestFunding_opt = None, channelType_opt = None) alice ! cmd exchangeStfu(f) alice2bob.expectMsgType[SpliceInit] @@ -1861,7 +1897,87 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik resolveHtlcs(f, htlcs) } - test("disconnect (commit_sig received by alice)") { f => + test("disconnect (commit_sig not received, missing current nonce)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + import f._ + + setupHtlcs(f) + val bobCommitIndex = bob.commitments.localCommitIndex + initiateSpliceWithoutSigs(f, spliceIn_opt = Some(SpliceIn(500_000 sat)), spliceOut_opt = Some(SpliceOut(100_000 sat, defaultSpliceOutScriptPubKey))) + alice2bob.expectMsgType[CommitSig] // Bob doesn't receive Alice's commit_sig + bob2alice.expectMsgType[CommitSig] + bob2alice.forward(alice) + awaitCond(alice.stateData.asInstanceOf[DATA_NORMAL].spliceStatus.isInstanceOf[SpliceStatus.SpliceWaitingForSigs]) + val spliceTxId = alice.stateData.asInstanceOf[DATA_NORMAL].spliceStatus.asInstanceOf[SpliceStatus.SpliceWaitingForSigs].signingSession.fundingTxId + + disconnect(f) + + val aliceInit = Init(alice.commitments.localChannelParams.initFeatures) + val bobInit = Init(bob.commitments.localChannelParams.initFeatures) + + alice ! INPUT_RECONNECTED(alice2bob.ref, aliceInit, bobInit) + val channelReestablishAlice = alice2bob.expectMsgType[ChannelReestablish] + assert(channelReestablishAlice.nextFundingTxId_opt.contains(spliceTxId)) + assert(channelReestablishAlice.currentCommitNonce_opt.isEmpty) + + bob ! INPUT_RECONNECTED(bob2alice.ref, bobInit, aliceInit) + val channelReestablishBob = bob2alice.expectMsgType[ChannelReestablish] + assert(channelReestablishBob.nextFundingTxId_opt.contains(spliceTxId)) + assert(channelReestablishBob.currentCommitNonce_opt.nonEmpty) + + // If Bob doesn't provide a nonce for Alice to retransmit her commit_sig, she cannot sign. + // We sent a warning and wait for Bob to fix his node instead of force-closing. + bob2alice.forward(alice, channelReestablishBob.copy(tlvStream = TlvStream(channelReestablishBob.tlvStream.records.filterNot(_.isInstanceOf[ChannelReestablishTlv.CurrentCommitNonceTlv])))) + assert(alice2bob.expectMsgType[Warning].toAscii == MissingCommitNonce(channelReestablishBob.channelId, spliceTxId, bobCommitIndex).getMessage) + alice2bob.expectNoMessage(100 millis) + assert(alice.stateName == NORMAL) + } + + test("disconnect (commit_sig not received, missing next nonce)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + import f._ + + setupHtlcs(f) + val aliceCommitIndex = alice.commitments.localCommitIndex + val bobCommitIndex = bob.commitments.localCommitIndex + val fundingTxId = alice.commitments.latest.fundingTxId + initiateSpliceWithoutSigs(f, spliceIn_opt = Some(SpliceIn(500_000 sat)), spliceOut_opt = Some(SpliceOut(100_000 sat, defaultSpliceOutScriptPubKey))) + alice2bob.expectMsgType[CommitSig] // Bob doesn't receive Alice's commit_sig + bob2alice.expectMsgType[CommitSig] // Alice doesn't receive Bob's commit_sig + awaitCond(alice.stateData.asInstanceOf[DATA_NORMAL].spliceStatus.isInstanceOf[SpliceStatus.SpliceWaitingForSigs]) + val spliceTxId = alice.stateData.asInstanceOf[DATA_NORMAL].spliceStatus.asInstanceOf[SpliceStatus.SpliceWaitingForSigs].signingSession.fundingTxId + + disconnect(f) + + val aliceInit = Init(alice.commitments.localChannelParams.initFeatures) + val bobInit = Init(bob.commitments.localChannelParams.initFeatures) + val aliceCommitTx = alice.signCommitTx() + val bobCommitTx = bob.signCommitTx() + + alice ! INPUT_RECONNECTED(alice2bob.ref, aliceInit, bobInit) + val channelReestablishAlice = alice2bob.expectMsgType[ChannelReestablish] + assert(channelReestablishAlice.nextFundingTxId_opt.contains(spliceTxId)) + assert(channelReestablishAlice.currentCommitNonce_opt.nonEmpty) + + bob ! INPUT_RECONNECTED(bob2alice.ref, bobInit, aliceInit) + val channelReestablishBob = bob2alice.expectMsgType[ChannelReestablish] + assert(channelReestablishBob.nextFundingTxId_opt.contains(spliceTxId)) + assert(channelReestablishBob.currentCommitNonce_opt.nonEmpty) + + // If Alice doesn't include a nonce for the previous funding transaction, Bob must force-close. + val noncesAlice1 = ChannelReestablishTlv.NextLocalNoncesTlv((channelReestablishAlice.nextCommitNonces - fundingTxId).toSeq) + val channelReestablishAlice1 = channelReestablishAlice.copy(tlvStream = TlvStream(channelReestablishAlice.tlvStream.records.filterNot(_.isInstanceOf[ChannelReestablishTlv.NextLocalNoncesTlv]) + noncesAlice1)) + alice2bob.forward(bob, channelReestablishAlice1) + assert(bob2alice.expectMsgType[Error].toAscii == MissingCommitNonce(channelReestablishAlice.channelId, fundingTxId, aliceCommitIndex + 1).getMessage) + bob2blockchain.expectFinalTxPublished(bobCommitTx.txid) + + // If Bob doesn't include a nonce for the splice transaction, Alice must force-close. + val noncesBob1 = ChannelReestablishTlv.NextLocalNoncesTlv((channelReestablishBob.nextCommitNonces - spliceTxId).toSeq) + val channelReestablishBob1 = channelReestablishBob.copy(tlvStream = TlvStream(channelReestablishBob.tlvStream.records.filterNot(_.isInstanceOf[ChannelReestablishTlv.NextLocalNoncesTlv]) + noncesBob1)) + bob2alice.forward(alice, channelReestablishBob1) + assert(alice2bob.expectMsgType[Error].toAscii == MissingCommitNonce(channelReestablishBob.channelId, spliceTxId, bobCommitIndex + 1).getMessage) + alice2blockchain.expectFinalTxPublished(aliceCommitTx.txid) + } + + def disconnectCommitSigReceivedAlice(f: FixtureParam, commitmentFormat: CommitmentFormat): Unit = { import f._ // Disconnection with both sides sending commit_sig // alice bob @@ -1879,8 +1995,9 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik // |----- tx_signatures -->| val htlcs = setupHtlcs(f) - val aliceCommitIndex = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommitIndex - val bobCommitIndex = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommitIndex + val aliceCommitIndex = alice.commitments.localCommitIndex + val bobCommitIndex = bob.commitments.localCommitIndex + val fundingTxId = alice.commitments.latest.fundingTxId assert(aliceCommitIndex != bobCommitIndex) val sender = initiateSpliceWithoutSigs(f, spliceIn_opt = Some(SpliceIn(500_000 sat)), spliceOut_opt = Some(SpliceOut(100_000 sat, defaultSpliceOutScriptPubKey))) @@ -1892,10 +2009,21 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik disconnect(f) val (channelReestablishAlice, channelReestablishBob) = reconnect(f) - assert(channelReestablishAlice.nextFundingTxId_opt.contains(spliceStatus.signingSession.fundingTx.txId)) + assert(channelReestablishAlice.nextFundingTxId_opt.contains(spliceStatus.signingSession.fundingTxId)) assert(channelReestablishAlice.nextLocalCommitmentNumber == aliceCommitIndex + 1) - assert(channelReestablishBob.nextFundingTxId_opt.contains(spliceStatus.signingSession.fundingTx.txId)) + assert(channelReestablishBob.nextFundingTxId_opt.contains(spliceStatus.signingSession.fundingTxId)) assert(channelReestablishBob.nextLocalCommitmentNumber == bobCommitIndex) + commitmentFormat match { + case _: SegwitV0CommitmentFormat => () + case _: SimpleTaprootChannelCommitmentFormat => + assert(channelReestablishAlice.currentCommitNonce_opt.isEmpty) + assert(channelReestablishBob.currentCommitNonce_opt.nonEmpty) + Seq(channelReestablishAlice, channelReestablishBob).foreach { channelReestablish => + assert(channelReestablish.nextCommitNonces.size == 2) + assert(channelReestablish.nextCommitNonces.contains(fundingTxId)) + assert(channelReestablish.nextCommitNonces.contains(spliceStatus.signingSession.fundingTxId)) + } + } // Alice retransmits commit_sig, and they exchange tx_signatures afterwards. bob2alice.expectNoMessage(100 millis) @@ -1908,7 +2036,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik alice2bob.forward(bob) sender.expectMsgType[RES_SPLICE] - val spliceTx = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localFundingStatus.signedTx_opt.get + val spliceTx = alice.commitments.latest.localFundingStatus.signedTx_opt.get alice2blockchain.expectWatchFundingConfirmed(spliceTx.txid) bob2blockchain.expectWatchFundingConfirmed(spliceTx.txid) alice ! WatchFundingConfirmedTriggered(BlockHeight(42), 0, spliceTx) @@ -1923,12 +2051,21 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik resolveHtlcs(f, htlcs) } - test("disconnect (commit_sig received by bob)") { f => + test("disconnect (commit_sig received by alice)", Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + disconnectCommitSigReceivedAlice(f, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) + } + + test("disconnect (commit_sig received by alice, taproot)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + disconnectCommitSigReceivedAlice(f, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) + } + + def disconnectCommitSigReceivedBob(f: FixtureParam, commitmentFormat: CommitmentFormat): Unit = { import f._ val htlcs = setupHtlcs(f) - val aliceCommitIndex = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommitIndex - val bobCommitIndex = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommitIndex + val aliceCommitIndex = alice.commitments.localCommitIndex + val bobCommitIndex = bob.commitments.localCommitIndex + val fundingTxId = alice.commitments.latest.fundingTxId assert(aliceCommitIndex != bobCommitIndex) val sender = initiateSpliceWithoutSigs(f, spliceIn_opt = Some(SpliceIn(500_000 sat)), spliceOut_opt = Some(SpliceOut(100_000 sat, defaultSpliceOutScriptPubKey))) @@ -1946,6 +2083,17 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik assert(channelReestablishAlice.nextLocalCommitmentNumber == aliceCommitIndex) assert(channelReestablishBob.nextFundingTxId_opt.contains(spliceStatus.signingSession.fundingTx.txId)) assert(channelReestablishBob.nextLocalCommitmentNumber == bobCommitIndex + 1) + commitmentFormat match { + case _: SegwitV0CommitmentFormat => () + case _: SimpleTaprootChannelCommitmentFormat => + assert(channelReestablishAlice.currentCommitNonce_opt.nonEmpty) + assert(channelReestablishBob.currentCommitNonce_opt.isEmpty) + Seq(channelReestablishAlice, channelReestablishBob).foreach { channelReestablish => + assert(channelReestablish.nextCommitNonces.size == 2) + assert(channelReestablish.nextCommitNonces.contains(fundingTxId)) + assert(channelReestablish.nextCommitNonces.contains(spliceStatus.signingSession.fundingTxId)) + } + } // Bob retransmit commit_sig and tx_signatures, Alice sends tx_signatures afterwards. bob2alice.expectMsgType[CommitSig] @@ -1957,7 +2105,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik alice2bob.forward(bob) sender.expectMsgType[RES_SPLICE] - val spliceTx = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localFundingStatus.signedTx_opt.get + val spliceTx = alice.commitments.latest.localFundingStatus.signedTx_opt.get alice2blockchain.expectWatchFundingConfirmed(spliceTx.txid) bob2blockchain.expectWatchFundingConfirmed(spliceTx.txid) alice ! WatchFundingConfirmedTriggered(BlockHeight(42), 0, spliceTx) @@ -1972,12 +2120,21 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik resolveHtlcs(f, htlcs) } - test("disconnect (commit_sig received)") { f => + test("disconnect (commit_sig received by bob)", Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + disconnectCommitSigReceivedBob(f, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) + } + + test("disconnect (commit_sig received by bob, taproot)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + disconnectCommitSigReceivedBob(f, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) + } + + def disconnectCommitSigReceived(f: FixtureParam, commitmentFormat: CommitmentFormat): Unit = { import f._ val htlcs = setupHtlcs(f) - val aliceCommitIndex = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommitIndex - val bobCommitIndex = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommitIndex + val aliceCommitIndex = alice.commitments.localCommitIndex + val bobCommitIndex = bob.commitments.localCommitIndex + val fundingTxId = alice.commitments.latest.fundingTxId val sender = initiateSpliceWithoutSigs(f, spliceIn_opt = Some(SpliceIn(500_000 sat)), spliceOut_opt = Some(SpliceOut(100_000 sat, defaultSpliceOutScriptPubKey))) alice2bob.expectMsgType[CommitSig] @@ -1991,8 +2148,18 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik val (channelReestablishAlice, channelReestablishBob) = reconnect(f) assert(channelReestablishAlice.nextFundingTxId_opt.contains(spliceTxId)) assert(channelReestablishAlice.nextLocalCommitmentNumber == aliceCommitIndex + 1) + assert(channelReestablishAlice.currentCommitNonce_opt.isEmpty) assert(channelReestablishBob.nextFundingTxId_opt.contains(spliceTxId)) assert(channelReestablishBob.nextLocalCommitmentNumber == bobCommitIndex + 1) + assert(channelReestablishBob.currentCommitNonce_opt.isEmpty) + commitmentFormat match { + case _: SegwitV0CommitmentFormat => () + case _: SimpleTaprootChannelCommitmentFormat => Seq(channelReestablishAlice, channelReestablishBob).foreach { channelReestablish => + assert(channelReestablish.nextCommitNonces.size == 2) + assert(channelReestablish.nextCommitNonces.contains(fundingTxId)) + assert(channelReestablish.nextCommitNonces.contains(spliceTxId)) + } + } bob2blockchain.expectWatchFundingConfirmed(spliceTxId) // Alice and Bob retransmit tx_signatures. @@ -2003,7 +2170,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik alice2bob.forward(bob) sender.expectMsgType[RES_SPLICE] - val spliceTx = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localFundingStatus.signedTx_opt.get + val spliceTx = alice.commitments.latest.localFundingStatus.signedTx_opt.get alice2blockchain.expectWatchFundingConfirmed(spliceTx.txid) alice ! WatchFundingConfirmedTriggered(BlockHeight(42), 0, spliceTx) alice2bob.expectMsgType[SpliceLocked] @@ -2011,13 +2178,21 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik bob ! WatchFundingConfirmedTriggered(BlockHeight(42), 0, spliceTx) bob2alice.expectMsgType[SpliceLocked] bob2alice.forward(alice) - awaitCond(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.active.size == 1) - awaitCond(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.active.size == 1) + awaitCond(alice.commitments.active.size == 1) + awaitCond(bob.commitments.active.size == 1) resolveHtlcs(f, htlcs) } - test("disconnect (tx_signatures received by alice)") { f => + test("disconnect (commit_sig received)", Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + disconnectCommitSigReceived(f, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) + } + + test("disconnect (commit_sig received, taproot)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + disconnectCommitSigReceived(f, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) + } + + def disconnectTxSigsReceivedAlice(f: FixtureParam, commitmentFormat: CommitmentFormat): Unit = { import f._ // Disconnection with both sides sending tx_signatures // alice bob @@ -2035,8 +2210,9 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik // |----- tx_signatures -->| val htlcs = setupHtlcs(f) - val aliceCommitIndex = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommitIndex - val bobCommitIndex = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommitIndex + val aliceCommitIndex = alice.commitments.localCommitIndex + val bobCommitIndex = bob.commitments.localCommitIndex + val fundingTxId = alice.commitments.latest.fundingTxId initiateSpliceWithoutSigs(f, spliceIn_opt = Some(SpliceIn(500_000 sat)), spliceOut_opt = Some(SpliceOut(100_000 sat, defaultSpliceOutScriptPubKey))) alice2bob.expectMsgType[CommitSig] @@ -2053,14 +2229,24 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik val (channelReestablishAlice, channelReestablishBob) = reconnect(f) assert(channelReestablishAlice.nextFundingTxId_opt.isEmpty) assert(channelReestablishAlice.nextLocalCommitmentNumber == aliceCommitIndex + 1) + assert(channelReestablishAlice.currentCommitNonce_opt.isEmpty) assert(channelReestablishBob.nextFundingTxId_opt.contains(spliceTxId)) assert(channelReestablishBob.nextLocalCommitmentNumber == bobCommitIndex + 1) + assert(channelReestablishBob.currentCommitNonce_opt.isEmpty) + commitmentFormat match { + case _: SegwitV0CommitmentFormat => () + case _: SimpleTaprootChannelCommitmentFormat => Seq(channelReestablishAlice, channelReestablishBob).foreach { channelReestablish => + assert(channelReestablish.nextCommitNonces.size == 2) + assert(channelReestablish.nextCommitNonces.contains(fundingTxId)) + assert(channelReestablish.nextCommitNonces.contains(spliceTxId)) + } + } alice2blockchain.expectWatchFundingConfirmed(spliceTxId) bob2blockchain.expectWatchFundingConfirmed(spliceTxId) - assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.active.size == 2) - assert(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.active.size == 2) - val spliceTx = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localFundingStatus.signedTx_opt.get + assert(alice.commitments.active.size == 2) + assert(bob.commitments.active.size == 2) + val spliceTx = alice.commitments.latest.localFundingStatus.signedTx_opt.get // Alice retransmits tx_signatures. alice2bob.expectMsgType[TxSignatures] @@ -2071,12 +2257,20 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik bob ! WatchFundingConfirmedTriggered(BlockHeight(42), 0, spliceTx) bob2alice.expectMsgType[SpliceLocked] bob2alice.forward(alice) - awaitCond(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.active.size == 1) - awaitCond(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.active.size == 1) + awaitCond(alice.commitments.active.size == 1) + awaitCond(bob.commitments.active.size == 1) resolveHtlcs(f, htlcs) } + test("disconnect (tx_signatures received by alice)", Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + disconnectTxSigsReceivedAlice(f, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) + } + + test("disconnect (tx_signatures received by alice, taproot)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + disconnectTxSigsReceivedAlice(f, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) + } + test("disconnect (tx_signatures received by alice, zero-conf)", Tag(ChannelStateTestsTags.ZeroConf), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => import f._ @@ -2292,10 +2486,11 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik resolveHtlcs(f, htlcs) } - test("disconnect (RBF commit_sig received by bob)") { f => + test("disconnect (RBF commit_sig received by bob)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => import f._ val htlcs = setupHtlcs(f) + val fundingTxId = alice.commitments.latest.fundingTxId val spliceTx = initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat)), spliceOut_opt = Some(SpliceOut(100_000 sat, defaultSpliceOutScriptPubKey))) assert(alice2blockchain.expectMsgType[WatchFundingConfirmed].txId == spliceTx.txid) @@ -2321,8 +2516,17 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik val (channelReestablishAlice, channelReestablishBob) = reconnect(f) assert(channelReestablishAlice.nextFundingTxId_opt.contains(rbfTxId)) assert(channelReestablishAlice.nextLocalCommitmentNumber == aliceCommitIndex) + assert(channelReestablishAlice.currentCommitNonce_opt.nonEmpty) assert(channelReestablishBob.nextFundingTxId_opt.contains(rbfTxId)) assert(channelReestablishBob.nextLocalCommitmentNumber == bobCommitIndex + 1) + assert(channelReestablishBob.currentCommitNonce_opt.isEmpty) + Seq(channelReestablishAlice, channelReestablishBob).foreach(channelReestablish => { + assert(channelReestablish.nextCommitNonces.size == 3) + assert(channelReestablish.nextCommitNonces.contains(fundingTxId)) + assert(channelReestablish.nextCommitNonces.contains(spliceTx.txid)) + assert(channelReestablish.nextCommitNonces.contains(rbfTxId)) + assert(channelReestablish.nextCommitNonces.values.toSet.size == 3) + }) bob2blockchain.expectWatchFundingConfirmed(spliceTx.txid) // Bob retransmits commit_sig, and they exchange tx_signatures afterwards. @@ -2702,7 +2906,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik bob2alice.expectNoMessage(100 millis) } - test("Disconnection after exchanging tx_signatures and both sides send commit_sig for channel update; revoke_and_ack not received", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + test("Disconnection after exchanging tx_signatures and both sides send commit_sig for channel update; revoke_and_ack not received") { f => import f._ // alice bob // | ... | @@ -3013,8 +3217,6 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik test("force-close with multiple splices (simple)") { f => import f._ - assume(extraTags.isEmpty) - val htlcs = setupHtlcs(f) val fundingTx1 = initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat))) @@ -3097,7 +3299,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik assert(Helpers.Closing.isClosed(bob.stateData.asInstanceOf[DATA_CLOSING], None).exists(_.isInstanceOf[RemoteClose])) } - test("force-close with multiple splices (previous active remote)", Tag(ChannelStateTestsTags.StaticRemoteKey), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + test("force-close with multiple splices (previous active remote)", Tag(ChannelStateTestsTags.OptionSimpleTaprootPhoenix)) { f => import f._ val htlcs = setupHtlcs(f) @@ -3352,7 +3554,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik assert(Helpers.Closing.isClosed(alice.stateData.asInstanceOf[DATA_CLOSING], None).exists(_.isInstanceOf[RemoteClose])) } - test("force-close with multiple splices (inactive revoked)", Tag(ChannelStateTestsTags.ZeroConf), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + test("force-close with multiple splices (inactive revoked)", Tag(ChannelStateTestsTags.ZeroConf), Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => import f._ val htlcs = setupHtlcs(f) @@ -3454,6 +3656,296 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik assert(Helpers.Closing.isClosed(alice.stateData.asInstanceOf[DATA_CLOSING], None).exists(_.isInstanceOf[RevokedClose])) } + test("force-close after channel type upgrade (latest active)", Tag(ChannelStateTestsTags.AnchorOutputs)) { f => + import f._ + + val htlcs = setupHtlcs(f) + + // Our first splice upgrades the channel to taproot. + val fundingTx1 = initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat)), channelType_opt = Some(ChannelTypes.SimpleTaprootChannelsPhoenix())) + checkWatchConfirmed(f, fundingTx1) + + // The first splice confirms on Bob's side. + bob ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx1) + bob2blockchain.expectMsgTypeHaving[WatchFundingSpent](_.txId == fundingTx1.txid) + bob2alice.expectMsgTypeHaving[SpliceLocked](_.fundingTxId == fundingTx1.txid) + bob2alice.forward(alice) + + // The second splice preserves the taproot commitment format. + val fundingTx2 = initiateSplice(f, spliceOut_opt = Some(SpliceOut(100_000 sat, defaultSpliceOutScriptPubKey))) + checkWatchConfirmed(f, fundingTx2) + assert(alice.commitments.active.map(_.commitmentFormat).count(_ == UnsafeLegacyAnchorOutputsCommitmentFormat) == 1) + assert(alice.commitments.active.map(_.commitmentFormat).count(_ == PhoenixSimpleTaprootChannelCommitmentFormat) == 2) + + // From Alice's point of view, we now have two unconfirmed splices. + alice ! CMD_FORCECLOSE(ActorRef.noSender) + alice2bob.expectMsgType[Error] + val commitTx2 = alice2blockchain.expectFinalTxPublished("commit-tx").tx + Transaction.correctlySpends(commitTx2, Seq(fundingTx2), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + val aliceAnchorTx = alice2blockchain.expectReplaceableTxPublished[ClaimLocalAnchorTx] + val claimMainAlice = alice2blockchain.expectFinalTxPublished("local-main-delayed") + Transaction.correctlySpends(claimMainAlice.tx, Seq(commitTx2), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + // Alice publishes her htlc timeout transactions. + val aliceHtlcTimeout = htlcs.aliceToBob.map(_ => alice2blockchain.expectReplaceableTxPublished[HtlcTimeoutTx]) + aliceHtlcTimeout.foreach(htlcTx => Transaction.correctlySpends(htlcTx.sign(), Seq(commitTx2), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)) + + // Bob detects Alice's commit tx. + bob ! WatchFundingSpentTriggered(commitTx2) + val bobAnchorTx = bob2blockchain.expectReplaceableTxPublished[ClaimRemoteAnchorTx] + val claimMainBob = bob2blockchain.expectFinalTxPublished("remote-main-delayed") + Transaction.correctlySpends(claimMainBob.tx, Seq(commitTx2), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + val bobHtlcTimeout = htlcs.bobToAlice.map(_ => bob2blockchain.expectReplaceableTxPublished[ClaimHtlcTimeoutTx]) + bobHtlcTimeout.foreach(htlcTx => Transaction.correctlySpends(htlcTx.sign(), Seq(commitTx2), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)) + bob2blockchain.expectWatchTxConfirmed(commitTx2.txid) + bob2blockchain.expectWatchOutputsSpent(Seq(claimMainBob.input, bobAnchorTx.input.outPoint) ++ aliceHtlcTimeout.map(_.input.outPoint) ++ bobHtlcTimeout.map(_.input.outPoint)) + alice2blockchain.expectWatchTxConfirmed(commitTx2.txid) + alice2blockchain.expectWatchOutputsSpent(Seq(claimMainAlice.input, aliceAnchorTx.input.outPoint) ++ aliceHtlcTimeout.map(_.input.outPoint) ++ bobHtlcTimeout.map(_.input.outPoint)) + + // The first splice transaction confirms. + alice ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx1) + alice2blockchain.expectMsgType[WatchFundingSpent] + + // The second splice transaction confirms. + alice ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx2) + alice2blockchain.expectMsgType[WatchFundingSpent] + + // Alice detects that the commit confirms, along with 2nd-stage and 3rd-stage transactions. + alice ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, commitTx2) + alice ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, claimMainAlice.tx) + aliceHtlcTimeout.foreach(htlcTx => { + alice ! WatchOutputSpentTriggered(0 sat, htlcTx.tx) + alice2blockchain.expectWatchTxConfirmed(htlcTx.tx.txid) + alice ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, htlcTx.tx) + val htlcDelayed = alice2blockchain.expectFinalTxPublished("htlc-delayed") + alice2blockchain.expectWatchOutputSpent(htlcDelayed.input) + alice ! WatchOutputSpentTriggered(0 sat, htlcDelayed.tx) + alice2blockchain.expectWatchTxConfirmed(htlcDelayed.tx.txid) + alice ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, htlcDelayed.tx) + }) + bobHtlcTimeout.foreach(htlcTx => { + alice ! WatchOutputSpentTriggered(htlcTx.amountIn, htlcTx.tx) + alice2blockchain.expectWatchTxConfirmed(htlcTx.tx.txid) + alice ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, htlcTx.tx) + }) + alice2blockchain.expectNoMessage(100 millis) + awaitCond(alice.stateName == CLOSED) + + // Bob also detects that the commit confirms, along with 2nd-stage transactions. + bob ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, commitTx2) + bob ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, claimMainBob.tx) + bobHtlcTimeout.foreach(htlcTx => { + bob ! WatchOutputSpentTriggered(htlcTx.amountIn, htlcTx.tx) + bob2blockchain.expectWatchTxConfirmed(htlcTx.tx.txid) + bob ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, htlcTx.tx) + }) + aliceHtlcTimeout.foreach(htlcTx => { + bob ! WatchOutputSpentTriggered(0 sat, htlcTx.tx) + bob2blockchain.expectWatchTxConfirmed(htlcTx.tx.txid) + bob ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, htlcTx.tx) + }) + bob2blockchain.expectNoMessage(100 millis) + awaitCond(bob.stateName == CLOSED) + + checkPostSpliceState(f, spliceOutFee(f, capacity = 1_900_000.sat, signedTx_opt = Some(fundingTx2))) + assert(Helpers.Closing.isClosed(alice.stateData.asInstanceOf[DATA_CLOSING], None).exists(_.isInstanceOf[LocalClose])) + assert(Helpers.Closing.isClosed(bob.stateData.asInstanceOf[DATA_CLOSING], None).exists(_.isInstanceOf[RemoteClose])) + } + + test("force-close after channel type upgrade (previous active)", Tag(ChannelStateTestsTags.AnchorOutputs)) { f => + import f._ + + val htlcs = setupHtlcs(f) + + // Our splice upgrades the channel to taproot. + val spliceTx = initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat)), channelType_opt = Some(ChannelTypes.SimpleTaprootChannelsPhoenix())) + assert(alice.commitments.active.head.commitmentFormat == PhoenixSimpleTaprootChannelCommitmentFormat) + assert(alice.commitments.active.last.commitmentFormat == UnsafeLegacyAnchorOutputsCommitmentFormat) + checkWatchConfirmed(f, spliceTx) + + // Alice force-closes using the non-taproot commitment. + val aliceCommitTx = alice.commitments.active.last.fullySignedLocalCommitTx(alice.commitments.channelParams, alice.underlyingActor.channelKeys) + bob ! WatchFundingSpentTriggered(aliceCommitTx) + assert(bob2blockchain.expectMsgType[WatchAlternativeCommitTxConfirmed].txId == aliceCommitTx.txid) + // Bob reacts by publishing the taproot commitment. + val bobCommitTx = bob2blockchain.expectFinalTxPublished("commit-tx").tx + Transaction.correctlySpends(bobCommitTx, Seq(spliceTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + val localAnchor = bob2blockchain.expectReplaceableTxPublished[ClaimLocalAnchorTx] + val localMain = bob2blockchain.expectFinalTxPublished("local-main-delayed") + htlcs.bobToAlice.map(_ => bob2blockchain.expectReplaceableTxPublished[HtlcTimeoutTx]) + bob2blockchain.expectWatchTxConfirmed(bobCommitTx.txid) + bob2blockchain.expectWatchOutputSpent(localMain.input) + bob2blockchain.expectWatchOutputSpent(localAnchor.input.outPoint) + (htlcs.aliceToBob.map(_._2) ++ htlcs.bobToAlice.map(_._2)).foreach(_ => bob2blockchain.expectMsgType[WatchOutputSpent]) + bob2blockchain.expectNoMessage(100 millis) + + // Alice's commit tx confirms. + bob ! WatchAlternativeCommitTxConfirmedTriggered(BlockHeight(450_000), 5, aliceCommitTx) + val anchorTx = bob2blockchain.expectReplaceableTxPublished[ClaimRemoteAnchorTx] + val mainTx = bob2blockchain.expectFinalTxPublished("remote-main-delayed") + Transaction.correctlySpends(mainTx.tx, Seq(aliceCommitTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + val bobHtlcTimeout = htlcs.bobToAlice.map(_ => bob2blockchain.expectReplaceableTxPublished[ClaimHtlcTimeoutTx]) + bobHtlcTimeout.foreach(htlcTx => Transaction.correctlySpends(htlcTx.sign(), Seq(aliceCommitTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)) + bob2blockchain.expectWatchTxConfirmed(aliceCommitTx.txid) + bob2blockchain.expectWatchOutputSpent(mainTx.input) + bob2blockchain.expectWatchOutputSpent(anchorTx.input.outPoint) + (htlcs.aliceToBob.map(_._2) ++ htlcs.bobToAlice.map(_._2)).foreach(_ => bob2blockchain.expectMsgType[WatchOutputSpent]) + bob2blockchain.expectNoMessage(100 millis) + } + + test("force-close after channel type upgrade (revoked previous active)", Tag(ChannelStateTestsTags.AnchorOutputs)) { f => + import f._ + + val htlcs = setupHtlcs(f) + + // Our splice upgrades the channel to taproot. + val spliceTx = initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat)), channelType_opt = Some(ChannelTypes.SimpleTaprootChannelsPhoenix())) + assert(alice.commitments.active.head.commitmentFormat == PhoenixSimpleTaprootChannelCommitmentFormat) + assert(alice.commitments.active.last.commitmentFormat == UnsafeLegacyAnchorOutputsCommitmentFormat) + checkWatchConfirmed(f, spliceTx) + + // Alice will force-close using a non-taproot revoked commitment. + val aliceCommitTx = alice.commitments.active.last.fullySignedLocalCommitTx(alice.commitments.channelParams, alice.underlyingActor.channelKeys) + addHtlc(20_000_000 msat, alice, bob, alice2bob, bob2alice) + crossSign(alice, bob, alice2bob, bob2alice) + bob ! WatchFundingSpentTriggered(aliceCommitTx) + assert(bob2blockchain.expectMsgType[WatchAlternativeCommitTxConfirmed].txId == aliceCommitTx.txid) + // Bob reacts by publishing the taproot commitment. + val bobCommitTx = bob2blockchain.expectFinalTxPublished("commit-tx").tx + Transaction.correctlySpends(bobCommitTx, Seq(spliceTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + val localAnchor = bob2blockchain.expectReplaceableTxPublished[ClaimLocalAnchorTx] + val localMain = bob2blockchain.expectFinalTxPublished("local-main-delayed") + htlcs.bobToAlice.map(_ => bob2blockchain.expectReplaceableTxPublished[HtlcTimeoutTx]) + bob2blockchain.expectWatchTxConfirmed(bobCommitTx.txid) + bob2blockchain.expectWatchOutputSpent(localMain.input) + bob2blockchain.expectWatchOutputSpent(localAnchor.input.outPoint) + (htlcs.aliceToBob.map(_._2) ++ htlcs.bobToAlice.map(_._2)).foreach(_ => bob2blockchain.expectMsgType[WatchOutputSpent]) + bob2blockchain.expectMsgType[WatchOutputSpent] // newly added HTLC + bob2blockchain.expectNoMessage(100 millis) + + // Alice's commit tx confirms. + bob ! WatchAlternativeCommitTxConfirmedTriggered(BlockHeight(450_000), 5, aliceCommitTx) + val mainTx = bob2blockchain.expectFinalTxPublished("remote-main-delayed") + Transaction.correctlySpends(mainTx.tx, Seq(aliceCommitTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + val penaltyTx = bob2blockchain.expectFinalTxPublished("main-penalty") + Transaction.correctlySpends(penaltyTx.tx, Seq(aliceCommitTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + val htlcPenalty = (htlcs.aliceToBob ++ htlcs.bobToAlice).map(_ => bob2blockchain.expectFinalTxPublished("htlc-penalty")) + htlcPenalty.foreach(penalty => Transaction.correctlySpends(penalty.tx, Seq(aliceCommitTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)) + bob2blockchain.expectWatchTxConfirmed(aliceCommitTx.txid) + bob2blockchain.expectWatchOutputsSpent(Seq(mainTx.input, penaltyTx.input) ++ htlcPenalty.map(_.input)) + bob2blockchain.expectNoMessage(100 millis) + + // Bob's penalty txs confirm. + bob ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, aliceCommitTx) + bob ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, mainTx.tx) + bob ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, penaltyTx.tx) + htlcPenalty.foreach { penalty => bob ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, penalty.tx) } + awaitCond(bob.stateName == CLOSED) + assert(Helpers.Closing.isClosed(bob.stateData.asInstanceOf[DATA_CLOSING], None).exists(_.isInstanceOf[RevokedClose])) + } + + test("force-close after channel type upgrade (revoked latest active)", Tag(ChannelStateTestsTags.AnchorOutputs)) { f => + import f._ + + val htlcs = setupHtlcs(f) + + // Our splice upgrades the channel to taproot. + val spliceTx = initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat)), channelType_opt = Some(ChannelTypes.SimpleTaprootChannelsPhoenix())) + assert(alice.commitments.active.head.commitmentFormat == PhoenixSimpleTaprootChannelCommitmentFormat) + assert(alice.commitments.active.last.commitmentFormat == UnsafeLegacyAnchorOutputsCommitmentFormat) + checkWatchConfirmed(f, spliceTx) + + // Alice will force-close using a taproot revoked commitment. + val aliceCommitTx = alice.commitments.active.head.fullySignedLocalCommitTx(alice.commitments.channelParams, alice.underlyingActor.channelKeys) + addHtlc(20_000_000 msat, alice, bob, alice2bob, bob2alice) + crossSign(alice, bob, alice2bob, bob2alice) + bob ! WatchFundingSpentTriggered(aliceCommitTx) + // Bob reacts by publishing penalty transactions. + val mainTx = bob2blockchain.expectFinalTxPublished("remote-main-delayed") + Transaction.correctlySpends(mainTx.tx, Seq(aliceCommitTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + val penaltyTx = bob2blockchain.expectFinalTxPublished("main-penalty") + Transaction.correctlySpends(penaltyTx.tx, Seq(aliceCommitTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + val htlcPenalty = (htlcs.aliceToBob ++ htlcs.bobToAlice).map(_ => bob2blockchain.expectFinalTxPublished("htlc-penalty")) + htlcPenalty.foreach(penalty => Transaction.correctlySpends(penalty.tx, Seq(aliceCommitTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)) + bob2blockchain.expectWatchTxConfirmed(aliceCommitTx.txid) + bob2blockchain.expectWatchOutputsSpent(Seq(mainTx.input, penaltyTx.input) ++ htlcPenalty.map(_.input)) + bob2blockchain.expectNoMessage(100 millis) + + // Bob's penalty txs confirm. + bob ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, aliceCommitTx) + bob ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, mainTx.tx) + bob ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, penaltyTx.tx) + htlcPenalty.foreach { penalty => bob ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, penalty.tx) } + awaitCond(bob.stateName == CLOSED) + assert(Helpers.Closing.isClosed(bob.stateData.asInstanceOf[DATA_CLOSING], None).exists(_.isInstanceOf[RevokedClose])) + } + + test("force-close after channel type upgrade (revoked previous inactive)", Tag(ChannelStateTestsTags.AnchorOutputs), Tag(ChannelStateTestsTags.ZeroConf)) { f => + import f._ + + val htlcs = setupHtlcs(f) + + // Our splice upgrades the channel to taproot. + val spliceTx = initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat)), channelType_opt = Some(ChannelTypes.SimpleTaprootChannelsPhoenix())) + assert(alice.commitments.active.head.commitmentFormat == PhoenixSimpleTaprootChannelCommitmentFormat) + assert(alice.commitments.active.last.commitmentFormat == UnsafeLegacyAnchorOutputsCommitmentFormat) + assert(alice2blockchain.expectMsgType[WatchPublished].txId == spliceTx.txid) + assert(bob2blockchain.expectMsgType[WatchPublished].txId == spliceTx.txid) + + // Alice will force-close using a non-taproot revoked inactive commitment. + val aliceCommitTx = alice.commitments.active.last.fullySignedLocalCommitTx(alice.commitments.channelParams, alice.underlyingActor.channelKeys) + // Alice and Bob send splice_locked: Alice's commitment is now inactive. + alice ! WatchPublishedTriggered(spliceTx) + alice2blockchain.expectWatchFundingConfirmed(spliceTx.txid) + alice2bob.expectMsgType[SpliceLocked] + alice2bob.forward(bob) + bob ! WatchPublishedTriggered(spliceTx) + bob2blockchain.expectWatchFundingConfirmed(spliceTx.txid) + bob2alice.expectMsgType[SpliceLocked] + bob2alice.forward(alice) + awaitCond(bob.commitments.active.size == 1) + awaitCond(bob.commitments.inactive.size == 1) + + // Alice and Bob update the channel: Alice's commitment is now inactive and revoked. + addHtlc(20_000_000 msat, alice, bob, alice2bob, bob2alice) + crossSign(alice, bob, alice2bob, bob2alice) + + // Alice publishes her revoked commitment: Bob reacts by publishing the latest commitment. + bob ! WatchFundingSpentTriggered(aliceCommitTx) + assert(bob2blockchain.expectMsgType[WatchAlternativeCommitTxConfirmed].txId == aliceCommitTx.txid) + // Bob reacts by publishing the taproot commitment. + val bobCommitTx = bob2blockchain.expectFinalTxPublished("commit-tx").tx + Transaction.correctlySpends(bobCommitTx, Seq(spliceTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + val localAnchor = bob2blockchain.expectReplaceableTxPublished[ClaimLocalAnchorTx] + val localMain = bob2blockchain.expectFinalTxPublished("local-main-delayed") + htlcs.bobToAlice.map(_ => bob2blockchain.expectReplaceableTxPublished[HtlcTimeoutTx]) + bob2blockchain.expectWatchTxConfirmed(bobCommitTx.txid) + bob2blockchain.expectWatchOutputSpent(localMain.input) + bob2blockchain.expectWatchOutputSpent(localAnchor.input.outPoint) + (htlcs.aliceToBob.map(_._2) ++ htlcs.bobToAlice.map(_._2)).foreach(_ => bob2blockchain.expectMsgType[WatchOutputSpent]) + bob2blockchain.expectMsgType[WatchOutputSpent] // newly added HTLC + bob2blockchain.expectNoMessage(100 millis) + + // Alice's revoked commit tx confirms. + bob ! WatchAlternativeCommitTxConfirmedTriggered(BlockHeight(450_000), 5, aliceCommitTx) + val mainTx = bob2blockchain.expectFinalTxPublished("remote-main-delayed") + Transaction.correctlySpends(mainTx.tx, Seq(aliceCommitTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + val penaltyTx = bob2blockchain.expectFinalTxPublished("main-penalty") + Transaction.correctlySpends(penaltyTx.tx, Seq(aliceCommitTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + val htlcPenalty = (htlcs.aliceToBob ++ htlcs.bobToAlice).map(_ => bob2blockchain.expectFinalTxPublished("htlc-penalty")) + htlcPenalty.foreach(penalty => Transaction.correctlySpends(penalty.tx, Seq(aliceCommitTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)) + bob2blockchain.expectWatchTxConfirmed(aliceCommitTx.txid) + bob2blockchain.expectWatchOutputsSpent(Seq(mainTx.input, penaltyTx.input) ++ htlcPenalty.map(_.input)) + bob2blockchain.expectNoMessage(100 millis) + + // Bob's penalty txs confirm. + bob ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, aliceCommitTx) + bob ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, mainTx.tx) + bob ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, penaltyTx.tx) + htlcPenalty.foreach { penalty => bob ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, penalty.tx) } + awaitCond(bob.stateName == CLOSED) + assert(Helpers.Closing.isClosed(bob.stateData.asInstanceOf[DATA_CLOSING], None).exists(_.isInstanceOf[RevokedClose])) + } + test("put back watches after restart") { f => import f._ @@ -3563,7 +4055,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik bob2blockchain.expectNoMessage(100 millis) } - test("recv CMD_SPLICE (splice-in + splice-out) with pre and post splice htlcs") { f => + def spliceWithPreAndPostHtlcs(f: FixtureParam, commitmentFormat: CommitmentFormat): Unit = { import f._ val htlcs = setupHtlcs(f) @@ -3574,11 +4066,13 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik crossSign(bob, alice, bob2alice, alice2bob) val aliceCommitments1 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments aliceCommitments1.active.foreach { c => + assert(c.commitmentFormat == commitmentFormat) val commitTx = c.fullySignedLocalCommitTx(aliceCommitments1.channelParams, alice.underlyingActor.channelKeys) Transaction.correctlySpends(commitTx, Map(c.fundingInput -> c.commitInput(alice.underlyingActor.channelKeys).txOut), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) } val bobCommitments1 = bob.stateData.asInstanceOf[DATA_NORMAL].commitments bobCommitments1.active.foreach { c => + assert(c.commitmentFormat == commitmentFormat) val commitTx = c.fullySignedLocalCommitTx(bobCommitments1.channelParams, bob.underlyingActor.channelKeys) Transaction.correctlySpends(commitTx, Map(c.fundingInput -> c.commitInput(bob.underlyingActor.channelKeys).txOut), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) } @@ -3600,6 +4094,14 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik resolveHtlcs(f, htlcs) } + test("recv CMD_SPLICE (splice-in + splice-out) with pre and post splice htlcs", Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + spliceWithPreAndPostHtlcs(f, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) + } + + test("recv CMD_SPLICE (splice-in + splice-out) with pre and post splice htlcs (taproot)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + spliceWithPreAndPostHtlcs(f, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) + } + test("recv CMD_SPLICE (splice-in + splice-out) with pending htlcs, resolved after splice locked", Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => import f._ @@ -3790,24 +4292,5 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik assert(finalState.commitments.latest.localCommit.spec.toLocal == 805_000_000.msat) assert(finalState.commitments.latest.localCommit.spec.toRemote == 695_000_000.msat) } -} - -// test taproot channels -class NormalSplicesStateWithTaprootChannelsSpec extends NormalSplicesStateSpec { - override val extraTags: Set[String] = Set(ChannelStateTestsTags.OptionSimpleTaproot) -} - -class NormalSplicesStateWithLegacyTaprootChannelsSpec extends NormalSplicesStateSpec { - override val extraTags: Set[String] = Set(ChannelStateTestsTags.OptionSimpleTaprootPhoenix) -} - -// test migration from anchor outputs to taproot channels during splices -class NormalSplicesStateUpgradeToLegacyTaprootChannelsSpec extends NormalSplicesStateSpec { - override val extraTags: Set[String] = Set(ChannelStateTestsTags.AnchorOutputs) - override val spliceChannelType_opt: Option[ChannelType] = Some(ChannelTypes.SimpleTaprootChannelsPhoenix(scidAlias = false, zeroConf = false)) -} -class NormalSplicesStateUpgradeToTaprootChannelsSpec extends NormalSplicesStateSpec { - override val extraTags: Set[String] = Set(ChannelStateTestsTags.AnchorOutputs) - override val spliceChannelType_opt: Option[ChannelType] = Some(ChannelTypes.SimpleTaprootChannelsStaging(scidAlias = false, zeroConf = false)) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala index 352f568405..5abc3b77b5 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala @@ -34,7 +34,7 @@ import fr.acinq.eclair.channel.fsm.Channel._ import fr.acinq.eclair.channel.publish.TxPublisher.{PublishFinalTx, PublishReplaceableTx} import fr.acinq.eclair.channel.states.ChannelStateTestsBase.PimpTestFSM import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags} -import fr.acinq.eclair.crypto.Sphinx +import fr.acinq.eclair.crypto.{NonceGenerator, Sphinx} import fr.acinq.eclair.io.Peer import fr.acinq.eclair.payment.OutgoingPaymentPacket import fr.acinq.eclair.payment.relay.Relayer._ @@ -44,7 +44,7 @@ import fr.acinq.eclair.testutils.PimpTestProbe.convert import fr.acinq.eclair.transactions.DirectedHtlc.{incoming, outgoing} import fr.acinq.eclair.transactions.Transactions import fr.acinq.eclair.transactions.Transactions._ -import fr.acinq.eclair.wire.protocol.{AnnouncementSignatures, ChannelUpdate, ClosingSigned, CommitSig, CommitSigTlv, Error, FailureMessageCodecs, FailureReason, PermanentChannelFailure, RevokeAndAck, RevokeAndAckTlv, Shutdown, TemporaryNodeFailure, TlvStream, UpdateAddHtlc, UpdateFailHtlc, UpdateFailMalformedHtlc, UpdateFee, UpdateFulfillHtlc, Warning} +import fr.acinq.eclair.wire.protocol.{AnnouncementSignatures, ChannelReestablish, ChannelReestablishTlv, ChannelUpdate, ClosingSigned, CommitSig, CommitSigTlv, Error, FailureMessageCodecs, FailureReason, Init, PermanentChannelFailure, RevokeAndAck, RevokeAndAckTlv, Shutdown, TemporaryNodeFailure, TlvStream, UpdateAddHtlc, UpdateFailHtlc, UpdateFailMalformedHtlc, UpdateFee, UpdateFulfillHtlc, Warning} import org.scalatest.Inside.inside import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.{Outcome, Tag} @@ -1103,7 +1103,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localCommit.htlcRemoteSigs.size == 5) } - test("recv CommitSig (multiple htlcs in both directions) (anchor outputs)", Tag(ChannelStateTestsTags.AnchorOutputs)) { f => + def testRecvCommitSigMultipleHtlcs(f: FixtureParam): Unit = { import f._ addHtlc(50000000 msat, alice, bob, alice2bob, bob2alice) // a->b (regular) @@ -1130,34 +1130,15 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localCommit.htlcRemoteSigs.size == 3) } - test("recv CommitSig (multiple htlcs in both directions) (simple taproot channels)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => - import f._ - - addHtlc(50000000 msat, alice, bob, alice2bob, bob2alice) // a->b (regular) - addHtlc(1100000 msat, alice, bob, alice2bob, bob2alice) // a->b (trimmed to dust) - addHtlc(999999 msat, bob, alice, bob2alice, alice2bob) // b->a (dust) - addHtlc(10000000 msat, alice, bob, alice2bob, bob2alice) // a->b (regular) - addHtlc(50000000 msat, bob, alice, bob2alice, alice2bob) // b->a (regular) - addHtlc(999999 msat, alice, bob, alice2bob, bob2alice) // a->b (dust) - addHtlc(1100000 msat, bob, alice, bob2alice, alice2bob) // b->a (trimmed to dust) - - alice ! CMD_SIGN() - val aliceCommitSig = alice2bob.expectMsgType[CommitSig] - assert(aliceCommitSig.htlcSignatures.length == 2) - alice2bob.forward(bob, aliceCommitSig) - bob2alice.expectMsgType[RevokeAndAck] - bob2alice.forward(alice) - - // actual test begins - val bobCommitSig = bob2alice.expectMsgType[CommitSig] - assert(bobCommitSig.htlcSignatures.length == 3) - bob2alice.forward(alice, bobCommitSig) + test("recv CommitSig (multiple htlcs in both directions) (anchor outputs)", Tag(ChannelStateTestsTags.AnchorOutputs)) { f => + testRecvCommitSigMultipleHtlcs(f) + } - awaitCond(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localCommit.index == 1) - assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localCommit.htlcRemoteSigs.size == 3) + test("recv CommitSig (multiple htlcs in both directions) (phoenix taproot)", Tag(ChannelStateTestsTags.OptionSimpleTaprootPhoenix)) { f => + testRecvCommitSigMultipleHtlcs(f) } - test("recv CommitSig (multiple htlcs in both directions) (anchor outputs zero fee htlc txs)", Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + def testRecvCommitSigMultipleHtlcZeroFees(f: FixtureParam): Unit = { import f._ addHtlc(50000000 msat, alice, bob, alice2bob, bob2alice) // a->b (regular) @@ -1184,6 +1165,14 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localCommit.htlcRemoteSigs.size == 5) } + test("recv CommitSig (multiple htlcs in both directions) (anchor outputs zero fee htlc txs)", Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + testRecvCommitSigMultipleHtlcZeroFees(f) + } + + test("recv CommitSig (multiple htlcs in both directions) (taproot zero fee htlc txs)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + testRecvCommitSigMultipleHtlcZeroFees(f) + } + test("recv CommitSig (multiple htlcs in both directions) (without fundingTxId tlv)") { f => import f._ @@ -1276,7 +1265,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with bob2blockchain.expectWatchTxConfirmed(tx.txid) } - test("recv CommitSig (simple taproot channels, missing nonce)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + test("recv CommitSig (simple taproot channels, missing partial signature)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => import f._ addHtlc(50000000 msat, alice, bob, alice2bob, bob2alice) val tx = bob.signCommitTx() @@ -1284,8 +1273,8 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // actual test begins alice ! CMD_SIGN() val commitSig = alice2bob.expectMsgType[CommitSig] - val commitSigWithMissingNonce = commitSig.copy(tlvStream = commitSig.tlvStream.copy(records = commitSig.tlvStream.records.filterNot(_.isInstanceOf[CommitSigTlv.PartialSignatureWithNonceTlv]))) - bob ! commitSigWithMissingNonce + val commitSigMissingPartialSig = commitSig.copy(tlvStream = commitSig.tlvStream.copy(records = commitSig.tlvStream.records.filterNot(_.isInstanceOf[CommitSigTlv.PartialSignatureWithNonceTlv]))) + bob ! commitSigMissingPartialSig val error = bob2alice.expectMsgType[Error] assert(new String(error.data.toArray).startsWith("invalid commitment signature")) awaitCond(bob.stateName == CLOSING) @@ -1304,8 +1293,8 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with alice ! CMD_SIGN() val commitSig = alice2bob.expectMsgType[CommitSig] val Some(psig) = commitSig.partialSignature_opt - val invalidPsig = psig.copy(partialSig = psig.partialSig.reverse) - val commitSigWithInvalidPsig = commitSig.copy(tlvStream = TlvStream(CommitSigTlv.PartialSignatureWithNonceTlv(invalidPsig))) + val invalidPsig = CommitSigTlv.PartialSignatureWithNonceTlv(psig.copy(partialSig = psig.partialSig.reverse)) + val commitSigWithInvalidPsig = commitSig.copy(tlvStream = commitSig.tlvStream.copy(records = commitSig.tlvStream.records.filterNot(_.isInstanceOf[CommitSigTlv.PartialSignatureWithNonceTlv]) + invalidPsig)) bob ! commitSigWithInvalidPsig val error = bob2alice.expectMsgType[Error] assert(new String(error.data.toArray).startsWith("invalid commitment signature")) @@ -1488,6 +1477,38 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with alice2blockchain.expectWatchTxConfirmed(tx.txid) } + test("recv RevokeAndAck (simple taproot channels, invalid nonce)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + import f._ + // Alice sends an HTLC to Bob. + val (r, add) = addHtlc(50000000 msat, alice, bob, alice2bob, bob2alice) + alice ! CMD_SIGN() + alice2bob.expectMsgType[CommitSig] + alice2bob.forward(bob) + // Bob responds with an invalid nonce for its *next* commitment. + val revokeAndAck = bob2alice.expectMsgType[RevokeAndAck] + val bobInvalidNonces = RevokeAndAckTlv.NextLocalNoncesTlv(revokeAndAck.nextCommitNonces.map { case (txId, _) => txId -> NonceGenerator.signingNonce(randomKey().publicKey, randomKey().publicKey, txId).publicNonce }.toSeq) + val revokeAndAckWithInvalidNonce = revokeAndAck.copy(tlvStream = revokeAndAck.tlvStream.copy(records = revokeAndAck.tlvStream.records.filterNot(tlv => tlv.isInstanceOf[RevokeAndAckTlv.NextLocalNoncesTlv]) + bobInvalidNonces)) + bob2alice.forward(alice, revokeAndAckWithInvalidNonce) + // This applies to the *next* commitment, there is no issue when finalizing the *current* commitment. + bob2alice.expectMsgType[CommitSig] + bob2alice.forward(alice) + alice2bob.expectMsgType[RevokeAndAck] + alice2bob.forward(bob) + // Bob will force-close when receiving Alice's next commit_sig. + val commitTx = bob.signCommitTx() + fulfillHtlc(add.id, r, bob, alice, bob2alice, alice2bob) + bob ! CMD_SIGN() + bob2alice.expectMsgType[CommitSig] + bob2alice.forward(alice) + alice2bob.expectMsgType[RevokeAndAck] + alice2bob.forward(bob) + alice2bob.expectMsgType[CommitSig] + alice2bob.forward(bob) + bob2alice.expectMsgType[Error] + awaitCond(bob.stateName == CLOSING) + bob2blockchain.expectFinalTxPublished(commitTx.txid) + } + test("recv RevokeAndAck (over max dust htlc exposure)") { f => import f._ val aliceCommitments = alice.stateData.asInstanceOf[DATA_NORMAL].commitments @@ -1733,6 +1754,10 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with testRevokeAndAckHtlcStaticRemoteKey _ } + test("recv RevokeAndAck (one htlc sent, option_simple_taproot)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { + testRevokeAndAckHtlcStaticRemoteKey _ + } + test("recv RevocationTimeout") { f => import f._ addHtlc(50000000 msat, alice, bob, alice2bob, bob2alice) @@ -1777,6 +1802,10 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with testReceiveCmdFulfillHtlc _ } + test("recv CMD_FULFILL_HTLC (option_simple_taproot)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { + testReceiveCmdFulfillHtlc _ + } + test("recv CMD_FULFILL_HTLC (unknown htlc id)") { f => import f._ val sender = TestProbe() @@ -1865,6 +1894,10 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with testUpdateFulfillHtlc _ } + test("recv UpdateFulfillHtlc (option_simple_taproot)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { + testUpdateFulfillHtlc _ + } + test("recv UpdateFulfillHtlc (sender has not signed htlc)") { f => import f._ val (r, htlc) = addHtlc(50000000 msat, alice, bob, alice2bob, bob2alice) @@ -1946,6 +1979,10 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with testCmdFailHtlc _ } + test("recv CMD_FAIL_HTLC (option_simple_taproot)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { + testCmdFailHtlc _ + } + test("recv CMD_FAIL_HTLC (with delay)") { f => import f._ val (_, htlc) = addHtlc(50000000 msat, alice, bob, alice2bob, bob2alice) @@ -2076,6 +2113,10 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with testUpdateFailHtlc _ } + test("recv UpdateFailHtlc (option_simple_taproot)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { + testUpdateFailHtlc _ + } + test("recv UpdateFailMalformedHtlc") { f => import f._ @@ -2179,6 +2220,10 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with testCmdUpdateFee _ } + test("recv CMD_UPDATE_FEE (simple taproot channel)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { + testCmdUpdateFee _ + } + test("recv CMD_UPDATE_FEE (over max dust htlc exposure)") { f => import f._ @@ -2582,6 +2627,10 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with testCmdClose(f, None) } + test("recv CMD_CLOSE (no pending htlcs) (simple taproot channel)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + testCmdClose(f, None) + } + test("recv CMD_CLOSE (with noSender)") { f => import f._ val sender = TestProbe() @@ -3597,6 +3646,10 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with testErrorAnchorOutputsWithHtlcs(f) } + test("recv Error (simple taproot channel)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + testErrorAnchorOutputsWithHtlcs(f) + } + test("recv Error (anchor outputs zero fee htlc txs, fee-bumping for commit txs without htlcs disabled)", Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs), Tag(ChannelStateTestsTags.DontSpendAnchorWithoutHtlcs)) { f => // We should ignore the disable flag since there are htlcs in the commitment (funds at risk). testErrorAnchorOutputsWithHtlcs(f) @@ -3628,6 +3681,10 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with testErrorAnchorOutputsWithoutHtlcs(f, commitFeeBumpDisabled = false) } + test("recv Error (simple taproot channel without htlcs)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + testErrorAnchorOutputsWithoutHtlcs(f, commitFeeBumpDisabled = false) + } + test("recv Error (anchor outputs zero fee htlc txs without htlcs, fee-bumping for commit txs without htlcs disabled)", Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs), Tag(ChannelStateTestsTags.DontSpendAnchorWithoutHtlcs)) { f => testErrorAnchorOutputsWithoutHtlcs(f, commitFeeBumpDisabled = true) } @@ -3829,6 +3886,80 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(alice.stateName == OFFLINE) } + test("recv INPUT_DISCONNECTED (with pending htlcs, taproot)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + import f._ + + alice2bob.ignoreMsg { case _: ChannelUpdate => true } + bob2alice.ignoreMsg { case _: ChannelUpdate => true } + + // Alice sends an HTLC to Bob. + val (ra1, htlcA1) = addHtlc(15_000_000 msat, alice, bob, alice2bob, bob2alice) + // Bob sends an HTLC to Alice. + val (rb, htlcB) = addHtlc(25_000_000 msat, bob, alice, bob2alice, alice2bob) + bob ! CMD_SIGN() + bob2alice.expectMsgType[CommitSig] + bob2alice.forward(alice) + val revA1 = alice2bob.expectMsgType[RevokeAndAck] // not received by Bob + alice2bob.expectMsgType[CommitSig] // not received by Bob + val (_, htlcA2) = addHtlc(10_000_000 msat, alice, bob, alice2bob, bob2alice) // not signed by either Alice or Bob + + alice ! INPUT_DISCONNECTED + val addSettledA = alice2relayer.expectMsgType[RES_ADD_SETTLED[Origin, HtlcResult]] + assert(addSettledA.htlc == htlcA2) + assert(addSettledA.result.isInstanceOf[HtlcResult.DisconnectedBeforeSigned]) + alice2relayer.expectNoMessage(100 millis) + awaitCond(alice.stateName == OFFLINE) + bob ! INPUT_DISCONNECTED + bob2relayer.expectNoMessage(100 millis) + awaitCond(bob.stateName == OFFLINE) + + // Alice and Bob finish signing the HTLCs on reconnection. + val aliceInit = Init(alice.underlyingActor.nodeParams.features.initFeatures()) + val bobInit = Init(bob.underlyingActor.nodeParams.features.initFeatures()) + alice ! INPUT_RECONNECTED(bob, aliceInit, bobInit) + bob ! INPUT_RECONNECTED(alice, bobInit, aliceInit) + assert(alice2bob.expectMsgType[ChannelReestablish].nextCommitNonces == revA1.nextCommitNonces) + alice2bob.forward(bob) + bob2alice.expectMsgType[ChannelReestablish] + bob2alice.forward(alice) + assert(alice2bob.expectMsgType[RevokeAndAck].nextCommitNonces == revA1.nextCommitNonces) + alice2bob.forward(bob) + assert(alice2bob.expectMsgType[UpdateAddHtlc].paymentHash == htlcA1.paymentHash) + alice2bob.forward(bob) + alice2bob.expectMsgType[CommitSig] + alice2bob.forward(bob) + bob2alice.expectMsgType[RevokeAndAck] + bob2alice.forward(alice) + bob2alice.expectMsgType[CommitSig] + bob2alice.forward(alice) + alice2bob.expectMsgType[RevokeAndAck] + alice2bob.forward(bob) + + // Alice and Bob fulfill the pending HTLCs. + fulfillHtlc(htlcA1.id, ra1, bob, alice, bob2alice, alice2bob) + fulfillHtlc(htlcB.id, rb, alice, bob, alice2bob, bob2alice) + crossSign(alice, bob, alice2bob, bob2alice) + } + + test("recv INPUT_DISCONNECTED (missing nonces, taproot)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + import f._ + + alice ! INPUT_DISCONNECTED + awaitCond(alice.stateName == OFFLINE) + bob ! INPUT_DISCONNECTED + awaitCond(bob.stateName == OFFLINE) + + val aliceInit = Init(alice.underlyingActor.nodeParams.features.initFeatures()) + val bobInit = Init(bob.underlyingActor.nodeParams.features.initFeatures()) + alice ! INPUT_RECONNECTED(bob, aliceInit, bobInit) + bob ! INPUT_RECONNECTED(alice, bobInit, aliceInit) + val channelReestablish = alice2bob.expectMsgType[ChannelReestablish] + assert(channelReestablish.nextCommitNonces.size == 1) + bob2alice.expectMsgType[ChannelReestablish] + alice2bob.forward(bob, channelReestablish.copy(tlvStream = TlvStream(channelReestablish.tlvStream.records.filterNot(_.isInstanceOf[ChannelReestablishTlv.NextLocalNoncesTlv])))) + bob2alice.expectMsgType[Error] + } + test("recv INPUT_DISCONNECTED (public channel)", Tag(ChannelStateTestsTags.ChannelsPublic), Tag(ChannelStateTestsTags.DoNotInterceptGossip)) { f => import f._ bob2alice.expectMsgType[AnnouncementSignatures] diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/f/ShutdownStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/f/ShutdownStateSpec.scala index 1b8581c7e5..069feb2553 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/f/ShutdownStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/f/ShutdownStateSpec.scala @@ -26,7 +26,7 @@ import fr.acinq.eclair.blockchain.fee.{FeeratePerKw, FeeratesPerKw} import fr.acinq.eclair.blockchain.{CurrentBlockHeight, CurrentFeerates} import fr.acinq.eclair.channel.ChannelSpendSignature.IndividualSignature import fr.acinq.eclair.channel._ -import fr.acinq.eclair.channel.publish.TxPublisher.{PublishFinalTx, PublishTx} +import fr.acinq.eclair.channel.publish.TxPublisher.{PublishFinalTx, PublishTx, SetChannelId} import fr.acinq.eclair.channel.states.ChannelStateTestsBase.PimpTestFSM import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags} import fr.acinq.eclair.payment._ @@ -35,8 +35,8 @@ import fr.acinq.eclair.payment.send.SpontaneousRecipient import fr.acinq.eclair.reputation.Reputation import fr.acinq.eclair.testutils.PimpTestProbe.convert import fr.acinq.eclair.transactions.Transactions -import fr.acinq.eclair.transactions.Transactions.{ClaimHtlcTimeoutTx, ClaimRemoteAnchorTx} -import fr.acinq.eclair.wire.protocol.{AnnouncementSignatures, ChannelUpdate, ClosingSigned, CommitSig, Error, FailureMessageCodecs, FailureReason, PermanentChannelFailure, RevokeAndAck, Shutdown, UpdateAddHtlc, UpdateFailHtlc, UpdateFailMalformedHtlc, UpdateFee, UpdateFulfillHtlc} +import fr.acinq.eclair.transactions.Transactions._ +import fr.acinq.eclair.wire.protocol.{AnnouncementSignatures, ChannelReestablish, ChannelUpdate, ClosingSigned, CommitSig, Error, FailureMessageCodecs, FailureReason, Init, PermanentChannelFailure, RevokeAndAck, Shutdown, UpdateAddHtlc, UpdateFailHtlc, UpdateFailMalformedHtlc, UpdateFee, UpdateFulfillHtlc} import fr.acinq.eclair.{BlockHeight, CltvExpiry, CltvExpiryDelta, MilliSatoshiLong, TestConstants, TestKitBaseClass, randomBytes32, randomKey} import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.{Outcome, Tag} @@ -157,11 +157,18 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit val initialState = bob.stateData.asInstanceOf[DATA_SHUTDOWN] bob ! CMD_FULFILL_HTLC(0, r1, None) val fulfill = bob2alice.expectMsgType[UpdateFulfillHtlc] - awaitCond(bob.stateData == initialState - .modify(_.commitments.changes.localChanges.proposed).using(_ :+ fulfill) + awaitCond(bob.stateData == initialState.modify(_.commitments.changes.localChanges.proposed).using(_ :+ fulfill) ) } + test("recv CMD_FULFILL_HTLC (taproot)", Tag(ChannelStateTestsTags.SimpleClose), Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + import f._ + val initialState = bob.stateData.asInstanceOf[DATA_SHUTDOWN] + bob ! CMD_FULFILL_HTLC(0, r1, None) + val fulfill = bob2alice.expectMsgType[UpdateFulfillHtlc] + awaitCond(bob.stateData == initialState.modify(_.commitments.changes.localChanges.proposed).using(_ :+ fulfill)) + } + test("recv CMD_FULFILL_HTLC (unknown htlc id)") { f => import f._ val sender = TestProbe() @@ -375,6 +382,22 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit awaitCond(alice.stateData.asInstanceOf[DATA_SHUTDOWN].commitments.remoteNextCommitInfo.isLeft) } + test("recv CMD_SIGN (taproot)", Tag(ChannelStateTestsTags.SimpleClose), Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + import f._ + val sender = TestProbe() + bob ! CMD_FULFILL_HTLC(0, r1, None) + bob2alice.expectMsgType[UpdateFulfillHtlc] + bob2alice.forward(alice) + bob ! CMD_SIGN(replyTo_opt = Some(sender.ref)) + sender.expectMsgType[RES_SUCCESS[CMD_SIGN]] + assert(bob2alice.expectMsgType[CommitSig].partialSignature_opt.nonEmpty) + bob2alice.forward(alice) + assert(alice2bob.expectMsgType[RevokeAndAck].nextCommitNonces.contains(bob.commitments.latest.fundingTxId)) + alice2bob.forward(bob) + assert(alice2bob.expectMsgType[CommitSig].partialSignature_opt.nonEmpty) + awaitCond(alice.stateData.asInstanceOf[DATA_SHUTDOWN].commitments.remoteNextCommitInfo.isLeft) + } + test("recv CMD_SIGN (no changes)") { f => import f._ val sender = TestProbe() @@ -471,6 +494,19 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit awaitCond(alice.stateName == NEGOTIATING) } + test("recv RevokeAndAck (no more htlcs on either side, taproot)", Tag(ChannelStateTestsTags.SimpleClose), Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + import f._ + // Bob fulfills the first HTLC. + fulfillHtlc(0, r1, bob, alice, bob2alice, alice2bob) + crossSign(bob, alice, bob2alice, alice2bob) + assert(alice.stateName == SHUTDOWN) + // Bob fulfills the second HTLC. + fulfillHtlc(1, r2, bob, alice, bob2alice, alice2bob) + crossSign(bob, alice, bob2alice, alice2bob) + awaitCond(alice.stateName == NEGOTIATING_SIMPLE) + awaitCond(bob.stateName == NEGOTIATING_SIMPLE) + } + test("recv RevokeAndAck (invalid preimage)") { f => import f._ val tx = bob.signCommitTx() @@ -954,6 +990,61 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit assert(alice.stateName == SHUTDOWN) } + def testInputRestored(f: FixtureParam, commitmentFormat: CommitmentFormat): Unit = { + import f._ + // Alice and Bob restart. + val aliceData = alice.underlyingActor.nodeParams.db.channels.getChannel(channelId(alice)).get + alice.setState(WAIT_FOR_INIT_INTERNAL, Nothing) + alice ! INPUT_RESTORED(aliceData) + alice2blockchain.expectMsgType[SetChannelId] + val fundingTxId = alice2blockchain.expectMsgType[WatchFundingSpent].txId + awaitCond(alice.stateName == OFFLINE) + val bobData = bob.underlyingActor.nodeParams.db.channels.getChannel(channelId(bob)).get + bob.setState(WAIT_FOR_INIT_INTERNAL, Nothing) + bob ! INPUT_RESTORED(bobData) + bob2blockchain.expectMsgType[SetChannelId] + bob2blockchain.expectMsgType[WatchFundingSpent] + awaitCond(bob.stateName == OFFLINE) + // They reconnect and provide nonces to resume HTLC settlement. + val aliceInit = Init(alice.underlyingActor.nodeParams.features.initFeatures()) + val bobInit = Init(bob.underlyingActor.nodeParams.features.initFeatures()) + alice ! INPUT_RECONNECTED(bob, aliceInit, bobInit) + bob ! INPUT_RECONNECTED(alice, bobInit, aliceInit) + val channelReestablishAlice = alice2bob.expectMsgType[ChannelReestablish] + val channelReestablishBob = bob2alice.expectMsgType[ChannelReestablish] + Seq(channelReestablishAlice, channelReestablishBob).foreach(channelReestablish => commitmentFormat match { + case _: SegwitV0CommitmentFormat => + assert(channelReestablish.currentCommitNonce_opt.isEmpty) + assert(channelReestablish.nextCommitNonces.isEmpty) + case _: TaprootCommitmentFormat => + assert(channelReestablish.currentCommitNonce_opt.isEmpty) + assert(channelReestablish.nextCommitNonces.contains(fundingTxId)) + }) + alice2bob.forward(bob, channelReestablishAlice) + bob2alice.forward(alice, channelReestablishBob) + // They retransmit shutdown. + alice2bob.expectMsgType[Shutdown] + alice2bob.forward(bob) + bob2alice.expectMsgType[Shutdown] + bob2alice.forward(alice) + // They resume HTLC settlement. + fulfillHtlc(0, r1, bob, alice, bob2alice, alice2bob) + crossSign(bob, alice, bob2alice, alice2bob) + assert(alice.stateName == SHUTDOWN) + fulfillHtlc(1, r2, bob, alice, bob2alice, alice2bob) + crossSign(bob, alice, bob2alice, alice2bob) + awaitCond(alice.stateName == NEGOTIATING_SIMPLE) + awaitCond(bob.stateName == NEGOTIATING_SIMPLE) + } + + test("recv INPUT_RESTORED", Tag(ChannelStateTestsTags.SimpleClose), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + testInputRestored(f, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) + } + + test("recv INPUT_RESTORED (taproot)", Tag(ChannelStateTestsTags.SimpleClose), Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + testInputRestored(f, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) + } + test("recv Error") { f => import f._ val aliceCommitTx = alice.signCommitTx() diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala index 822cafd7b3..0c3c2280ff 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala @@ -29,9 +29,9 @@ import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsT import fr.acinq.eclair.reputation.Reputation import fr.acinq.eclair.testutils.PimpTestProbe._ import fr.acinq.eclair.transactions.Transactions -import fr.acinq.eclair.transactions.Transactions.ZeroFeeHtlcTxAnchorOutputsCommitmentFormat +import fr.acinq.eclair.transactions.Transactions._ import fr.acinq.eclair.wire.protocol.ClosingSignedTlv.FeeRange -import fr.acinq.eclair.wire.protocol.{AnnouncementSignatures, ChannelUpdate, ClosingComplete, ClosingSig, ClosingSigned, ClosingTlv, Error, Shutdown, TlvStream, Warning} +import fr.acinq.eclair.wire.protocol.{AnnouncementSignatures, ChannelUpdate, ClosingComplete, ClosingCompleteTlv, ClosingSig, ClosingSigTlv, ClosingSigned, ClosingTlv, Error, Shutdown, TlvStream, Warning} import fr.acinq.eclair.{BlockHeight, CltvExpiry, Features, MilliSatoshiLong, TestConstants, TestKitBaseClass, randomBytes32, randomKey} import org.scalatest.Inside.inside import org.scalatest.funsuite.FixtureAnyFunSuiteLike @@ -63,8 +63,10 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike alice ! CMD_CLOSE(sender.ref, None, feerates) sender.expectMsgType[RES_SUCCESS[CMD_CLOSE]] val aliceShutdown = alice2bob.expectMsgType[Shutdown] + if (alice.commitments.latest.commitmentFormat.isInstanceOf[TaprootCommitmentFormat]) assert(aliceShutdown.closeeNonce_opt.nonEmpty) alice2bob.forward(bob, aliceShutdown) val bobShutdown = bob2alice.expectMsgType[Shutdown] + if (bob.commitments.latest.commitmentFormat.isInstanceOf[TaprootCommitmentFormat]) assert(bobShutdown.closeeNonce_opt.nonEmpty) bob2alice.forward(alice, bobShutdown) if (alice.stateData.asInstanceOf[ChannelDataWithCommitments].commitments.localChannelParams.initFeatures.hasFeature(Features.SimpleClose)) { awaitCond(alice.stateName == NEGOTIATING_SIMPLE) @@ -83,8 +85,10 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike bob ! CMD_CLOSE(sender.ref, None, feerates) sender.expectMsgType[RES_SUCCESS[CMD_CLOSE]] val bobShutdown = bob2alice.expectMsgType[Shutdown] + if (bob.commitments.latest.commitmentFormat.isInstanceOf[TaprootCommitmentFormat]) assert(bobShutdown.closeeNonce_opt.nonEmpty) bob2alice.forward(alice, bobShutdown) val aliceShutdown = alice2bob.expectMsgType[Shutdown] + if (alice.commitments.latest.commitmentFormat.isInstanceOf[TaprootCommitmentFormat]) assert(aliceShutdown.closeeNonce_opt.nonEmpty) alice2bob.forward(bob, aliceShutdown) if (bob.stateData.asInstanceOf[ChannelDataWithCommitments].commitments.localChannelParams.initFeatures.hasFeature(Features.SimpleClose)) { awaitCond(alice.stateName == NEGOTIATING_SIMPLE) @@ -484,25 +488,41 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike bob2blockchain.expectMsgType[WatchTxConfirmed] } - def `recv ClosingComplete (both outputs)`(f: FixtureParam): Unit = { + def testReceiveClosingCompleteBothOutputs(f: FixtureParam, commitmentFormat: CommitmentFormat): Unit = { import f._ aliceClose(f) val aliceClosingComplete = alice2bob.expectMsgType[ClosingComplete] assert(aliceClosingComplete.fees > 0.sat) - assert(aliceClosingComplete.closerAndCloseeOutputsSig_opt.orElse(aliceClosingComplete.closerAndCloseeOutputsPartialSig_opt).nonEmpty) - assert(aliceClosingComplete.closerOutputOnlySig_opt.orElse(aliceClosingComplete.closerOutputOnlyPartialSig_opt).nonEmpty) + commitmentFormat match { + case _: SegwitV0CommitmentFormat => + assert(aliceClosingComplete.closerAndCloseeOutputsSig_opt.nonEmpty) + assert(aliceClosingComplete.closerOutputOnlySig_opt.nonEmpty) + case _: TaprootCommitmentFormat => + assert(aliceClosingComplete.closerAndCloseeOutputsPartialSig_opt.nonEmpty) + assert(aliceClosingComplete.closerOutputOnlyPartialSig_opt.nonEmpty) + } assert(aliceClosingComplete.closeeOutputOnlySig_opt.orElse(aliceClosingComplete.closeeOutputOnlyPartialSig_opt).isEmpty) val bobClosingComplete = bob2alice.expectMsgType[ClosingComplete] assert(bobClosingComplete.fees > 0.sat) - assert(bobClosingComplete.closerAndCloseeOutputsSig_opt.orElse(bobClosingComplete.closerAndCloseeOutputsPartialSig_opt).nonEmpty) - assert(bobClosingComplete.closerOutputOnlySig_opt.orElse(bobClosingComplete.closerOutputOnlyPartialSig_opt).nonEmpty) + commitmentFormat match { + case _: SegwitV0CommitmentFormat => + assert(bobClosingComplete.closerAndCloseeOutputsSig_opt.nonEmpty) + assert(bobClosingComplete.closerOutputOnlySig_opt.nonEmpty) + case _: TaprootCommitmentFormat => + assert(bobClosingComplete.closerAndCloseeOutputsPartialSig_opt.nonEmpty) + assert(bobClosingComplete.closerOutputOnlyPartialSig_opt.nonEmpty) + } assert(bobClosingComplete.closeeOutputOnlySig_opt.orElse(bobClosingComplete.closeeOutputOnlyPartialSig_opt).isEmpty) alice2bob.forward(bob, aliceClosingComplete) val bobClosingSig = bob2alice.expectMsgType[ClosingSig] assert(bobClosingSig.fees == aliceClosingComplete.fees) assert(bobClosingSig.lockTime == aliceClosingComplete.lockTime) + commitmentFormat match { + case _: SegwitV0CommitmentFormat => assert(bobClosingSig.closerAndCloseeOutputsSig_opt.nonEmpty) + case _: TaprootCommitmentFormat => assert(bobClosingSig.closerAndCloseeOutputsPartialSig_opt.nonEmpty) + } bob2alice.forward(alice, bobClosingSig) val aliceTx = alice2blockchain.expectMsgType[PublishFinalTx] assert(aliceTx.desc == "closing-tx") @@ -519,6 +539,10 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike val aliceClosingSig = alice2bob.expectMsgType[ClosingSig] assert(aliceClosingSig.fees == bobClosingComplete.fees) assert(aliceClosingSig.lockTime == bobClosingComplete.lockTime) + commitmentFormat match { + case _: SegwitV0CommitmentFormat => assert(aliceClosingSig.closerAndCloseeOutputsSig_opt.nonEmpty) + case _: TaprootCommitmentFormat => assert(aliceClosingSig.closerAndCloseeOutputsPartialSig_opt.nonEmpty) + } alice2bob.forward(bob, aliceClosingSig) val bobTx = bob2blockchain.expectMsgType[PublishFinalTx] assert(bobTx.desc == "closing-tx") @@ -533,27 +557,36 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike assert(bob.stateName == NEGOTIATING_SIMPLE) } - test("recv ClosingComplete (both outputs)", Tag(ChannelStateTestsTags.SimpleClose)) { f => - `recv ClosingComplete (both outputs)`(f) + test("recv ClosingComplete (both outputs)", Tag(ChannelStateTestsTags.SimpleClose), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + testReceiveClosingCompleteBothOutputs(f, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) } test("recv ClosingComplete (both outputs, simple taproot channels)", Tag(ChannelStateTestsTags.SimpleClose), Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => - `recv ClosingComplete (both outputs)`(f) + testReceiveClosingCompleteBothOutputs(f, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) } - test("recv ClosingComplete (single output)", Tag(ChannelStateTestsTags.SimpleClose), Tag(ChannelStateTestsTags.NoPushAmount)) { f => + def testReceiveClosingCompleteSingleOutput(f: FixtureParam, commitmentFormat: CommitmentFormat): Unit = { import f._ aliceClose(f) val closingComplete = alice2bob.expectMsgType[ClosingComplete] + commitmentFormat match { + case _: SegwitV0CommitmentFormat => assert(closingComplete.closerOutputOnlySig_opt.nonEmpty) + case _: TaprootCommitmentFormat => assert(closingComplete.closerOutputOnlyPartialSig_opt.nonEmpty) + } assert(closingComplete.closerAndCloseeOutputsSig_opt.isEmpty) - assert(closingComplete.closerOutputOnlySig_opt.nonEmpty) + assert(closingComplete.closerAndCloseeOutputsPartialSig_opt.isEmpty) assert(closingComplete.closeeOutputOnlySig_opt.isEmpty) + assert(closingComplete.closeeOutputOnlyPartialSig_opt.isEmpty) // Bob has nothing at stake. bob2alice.expectNoMessage(100 millis) alice2bob.forward(bob, closingComplete) - bob2alice.expectMsgType[ClosingSig] - bob2alice.forward(alice) + val closingSig = bob2alice.expectMsgType[ClosingSig] + commitmentFormat match { + case _: SegwitV0CommitmentFormat => assert(closingSig.closerOutputOnlySig_opt.nonEmpty) + case _: TaprootCommitmentFormat => assert(closingSig.closerOutputOnlyPartialSig_opt.nonEmpty) + } + bob2alice.forward(alice, closingSig) val closingTx = alice2blockchain.expectMsgType[PublishFinalTx] assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == closingTx.tx.txid) alice2blockchain.expectWatchTxConfirmed(closingTx.tx.txid) @@ -562,6 +595,14 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike assert(bob.stateName == NEGOTIATING_SIMPLE) } + test("recv ClosingComplete (single output)", Tag(ChannelStateTestsTags.SimpleClose), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs), Tag(ChannelStateTestsTags.NoPushAmount)) { f => + testReceiveClosingCompleteSingleOutput(f, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) + } + + test("recv ClosingComplete (single output, taproot)", Tag(ChannelStateTestsTags.SimpleClose), Tag(ChannelStateTestsTags.OptionSimpleTaproot), Tag(ChannelStateTestsTags.NoPushAmount)) { f => + testReceiveClosingCompleteSingleOutput(f, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) + } + test("recv ClosingComplete (single output, trimmed)", Tag(ChannelStateTestsTags.SimpleClose), Tag(ChannelStateTestsTags.NoPushAmount)) { f => import f._ val (r, htlc) = addHtlc(250_000 msat, alice, bob, alice2bob, bob2alice) @@ -590,24 +631,40 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike assert(bob.stateName == NEGOTIATING_SIMPLE) } - test("recv ClosingComplete (missing closee output)", Tag(ChannelStateTestsTags.SimpleClose)) { f => + def testReceiveClosingCompleteMissingCloseeOutput(f: FixtureParam, commitmentFormat: CommitmentFormat): Unit = { import f._ aliceClose(f) val aliceClosingComplete = alice2bob.expectMsgType[ClosingComplete] val bobClosingComplete = bob2alice.expectMsgType[ClosingComplete] - alice2bob.forward(bob, aliceClosingComplete.copy(tlvStream = TlvStream(ClosingTlv.CloserOutputOnly(aliceClosingComplete.closerOutputOnlySig_opt.get)))) + val aliceClosingComplete1 = commitmentFormat match { + case _: SegwitV0CommitmentFormat => aliceClosingComplete.copy(tlvStream = TlvStream(ClosingTlv.CloserOutputOnly(aliceClosingComplete.closerOutputOnlySig_opt.get))) + case _: TaprootCommitmentFormat => aliceClosingComplete.copy(tlvStream = TlvStream(ClosingCompleteTlv.CloserOutputOnlyPartialSignature(aliceClosingComplete.closerOutputOnlyPartialSig_opt.get))) + } + alice2bob.forward(bob, aliceClosingComplete1) // Bob expects to receive a signature for a closing transaction containing his output, so he ignores Alice's // closing_complete instead of sending back his closing_sig. bob2alice.expectMsgType[Warning] bob2alice.expectNoMessage(100 millis) bob2alice.forward(alice, bobClosingComplete) val aliceClosingSig = alice2bob.expectMsgType[ClosingSig] - alice2bob.forward(bob, aliceClosingSig.copy(tlvStream = TlvStream(ClosingTlv.CloseeOutputOnly(aliceClosingSig.closerAndCloseeOutputsSig_opt.get)))) + val aliceClosingSig1 = commitmentFormat match { + case _: SegwitV0CommitmentFormat => aliceClosingSig.copy(tlvStream = TlvStream(ClosingTlv.CloseeOutputOnly(aliceClosingSig.closerAndCloseeOutputsSig_opt.get))) + case _: TaprootCommitmentFormat => aliceClosingSig.copy(tlvStream = TlvStream(ClosingSigTlv.CloseeOutputOnlyPartialSignature(aliceClosingSig.closerAndCloseeOutputsPartialSig_opt.get))) + } + alice2bob.forward(bob, aliceClosingSig1) bob2alice.expectMsgType[Warning] bob2alice.expectNoMessage(100 millis) bob2blockchain.expectNoMessage(100 millis) } + test("recv ClosingComplete (missing closee output)", Tag(ChannelStateTestsTags.SimpleClose), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + testReceiveClosingCompleteMissingCloseeOutput(f, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) + } + + test("recv ClosingComplete (missing closee output, taproot)", Tag(ChannelStateTestsTags.SimpleClose), Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + testReceiveClosingCompleteMissingCloseeOutput(f, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) + } + test("recv ClosingComplete (with concurrent script update)", Tag(ChannelStateTestsTags.SimpleClose)) { f => import f._ aliceClose(f) @@ -890,6 +947,38 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike awaitCond(bob.stateName == CLOSING) } + test("recv CMD_CLOSE with RBF feerates (taproot)", Tag(ChannelStateTestsTags.SimpleClose), Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + import f._ + // Alice creates a first closing transaction. + aliceClose(f) + alice2bob.expectMsgType[ClosingComplete] + alice2bob.forward(bob) + bob2alice.expectMsgType[ClosingComplete] // ignored + val aliceTx1 = bob2blockchain.expectMsgType[PublishFinalTx] + bob2blockchain.expectWatchTxConfirmed(aliceTx1.tx.txid) + val closingSig1 = bob2alice.expectMsgType[ClosingSig] + assert(closingSig1.nextCloseeNonce_opt.nonEmpty) + bob2alice.forward(alice, closingSig1) + alice2blockchain.expectFinalTxPublished(aliceTx1.tx.txid) + alice2blockchain.expectWatchTxConfirmed(aliceTx1.tx.txid) + + // Alice sends another closing_complete, updating her fees. + val probe = TestProbe() + val aliceFeerate2 = alice.stateData.asInstanceOf[DATA_NEGOTIATING_SIMPLE].lastClosingFeerate * 1.25 + alice ! CMD_CLOSE(probe.ref, None, Some(ClosingFeerates(aliceFeerate2, aliceFeerate2, aliceFeerate2))) + probe.expectMsgType[RES_SUCCESS[CMD_CLOSE]] + assert(alice2bob.expectMsgType[ClosingComplete].fees > aliceTx1.fee) + alice2bob.forward(bob) + val aliceTx2 = bob2blockchain.expectMsgType[PublishFinalTx] + bob2blockchain.expectWatchTxConfirmed(aliceTx2.tx.txid) + val closingSig2 = bob2alice.expectMsgType[ClosingSig] + assert(closingSig2.nextCloseeNonce_opt.nonEmpty) + assert(closingSig2.nextCloseeNonce_opt != closingSig1.nextCloseeNonce_opt) + bob2alice.forward(alice, closingSig2) + alice2blockchain.expectFinalTxPublished(aliceTx2.tx.txid) + alice2blockchain.expectWatchTxConfirmed(aliceTx2.tx.txid) + } + test("recv CMD_CLOSE with RBF feerate too low", Tag(ChannelStateTestsTags.SimpleClose)) { f => import f._ diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala index 1690a57a79..7aa37ed3e6 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala @@ -53,11 +53,8 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with case class FixtureParam(alice: TestFSMRef[ChannelState, ChannelData, Channel], bob: TestFSMRef[ChannelState, ChannelData, Channel], alice2bob: TestProbe, bob2alice: TestProbe, alice2blockchain: TestProbe, bob2blockchain: TestProbe, alice2relayer: TestProbe, bob2relayer: TestProbe, channelUpdateListener: TestProbe, txListener: TestProbe, eventListener: TestProbe, bobCommitTxs: List[Transaction]) - val extraTags: Set[String] = Set(ChannelStateTestsTags.OptionSimpleTaproot) - override def withFixture(test: OneArgTest): Outcome = { - val tags = test.tags ++ extraTags - val setup = init(tags = tags) + val setup = init() import setup._ // NOTE @@ -69,13 +66,13 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // and we want to be able to test the different scenarii. // Hence the WAIT_FOR_FUNDING_CONFIRMED->CLOSING or NORMAL->CLOSING transition will occur in the individual tests. - val unconfirmedFundingTx = tags.contains("funding_unconfirmed") + val unconfirmedFundingTx = test.tags.contains("funding_unconfirmed") val txListener = TestProbe() val eventListener = TestProbe() if (unconfirmedFundingTx) { within(30 seconds) { - val channelParams = computeChannelParams(setup, tags) + val channelParams = computeChannelParams(setup, test.tags) alice ! channelParams.initChannelAlice(TestConstants.fundingSatoshis, pushAmount_opt = Some(TestConstants.initiatorPushAmount)) alice2blockchain.expectMsgType[SetChannelId] bob ! channelParams.initChannelBob() @@ -104,7 +101,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with } } else { within(30 seconds) { - reachNormal(setup, tags) + reachNormal(setup, test.tags) if (test.tags.contains(ChannelStateTestsTags.ChannelsPublic) && test.tags.contains(ChannelStateTestsTags.DoNotInterceptGossip)) { alice2bob.expectMsgType[AnnouncementSignatures] alice2bob.forward(bob) @@ -145,7 +142,6 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with alice ! CMD_FORCECLOSE(sender.ref) awaitCond(alice.stateName == CLOSING) alice2blockchain.expectFinalTxPublished("commit-tx") - alice2blockchain.expectReplaceableTxPublished[ClaimLocalAnchorTx] alice2blockchain.expectFinalTxPublished("local-main-delayed") eventListener.expectMsgType[ChannelAborted] @@ -161,7 +157,6 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with alice ! CMD_FORCECLOSE(sender.ref) awaitCond(alice.stateName == CLOSING) alice2blockchain.expectFinalTxPublished("commit-tx") - alice2blockchain.expectReplaceableTxPublished[ClaimLocalAnchorTx] alice2blockchain.expectFinalTxPublished("local-main-delayed") eventListener.expectMsgType[ChannelAborted] @@ -179,11 +174,9 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(alice.stateName == CLOSING) alice2bob.expectMsgType[Error] val commitTx = alice2blockchain.expectFinalTxPublished("commit-tx").tx - val claimLocalAnchor = alice2blockchain.expectReplaceableTxPublished[ClaimLocalAnchorTx] val claimMain = alice2blockchain.expectFinalTxPublished("local-main-delayed").tx alice2blockchain.expectWatchTxConfirmed(commitTx.txid) alice2blockchain.expectWatchOutputSpent(claimMain.txIn.head.outPoint) - alice2blockchain.expectWatchOutputSpent(claimLocalAnchor.tx.txIn.head.outPoint) eventListener.expectMsgType[ChannelAborted] // test starts here @@ -201,11 +194,9 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(alice.stateName == CLOSING) alice2bob.expectMsgType[Error] val commitTx = alice2blockchain.expectFinalTxPublished("commit-tx").tx - val claimLocalAnchor = alice2blockchain.expectReplaceableTxPublished[ClaimLocalAnchorTx] val claimMain = alice2blockchain.expectFinalTxPublished("local-main-delayed").tx alice2blockchain.expectWatchTxConfirmed(commitTx.txid) alice2blockchain.expectWatchOutputSpent(claimMain.txIn.head.outPoint) - alice2blockchain.expectWatchOutputSpent(claimLocalAnchor.tx.txIn.head.outPoint) eventListener.expectMsgType[ChannelAborted] // test starts here @@ -224,11 +215,9 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(bob.stateName == CLOSING) bob2alice.expectMsgType[Error] val commitTx = bob2blockchain.expectFinalTxPublished("commit-tx").tx - val claimLocalAnchor = bob2blockchain.expectReplaceableTxPublished[ClaimLocalAnchorTx] val claimMain = bob2blockchain.expectFinalTxPublished("local-main-delayed").tx bob2blockchain.expectWatchTxConfirmed(commitTx.txid) bob2blockchain.expectWatchOutputSpent(claimMain.txIn.head.outPoint) - bob2blockchain.expectWatchOutputSpent(claimLocalAnchor.tx.txIn.head.outPoint) eventListener.expectMsgType[ChannelAborted] // test starts here @@ -246,11 +235,9 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(bob.stateName == CLOSING) bob2alice.expectMsgType[Error] val commitTx = bob2blockchain.expectFinalTxPublished("commit-tx").tx - val claimLocalAnchor = bob2blockchain.expectReplaceableTxPublished[ClaimLocalAnchorTx] val claimMain = bob2blockchain.expectFinalTxPublished("local-main-delayed").tx bob2blockchain.expectWatchTxConfirmed(commitTx.txid) bob2blockchain.expectWatchOutputSpent(claimMain.txIn.head.outPoint) - bob2blockchain.expectWatchOutputSpent(claimLocalAnchor.tx.txIn.head.outPoint) eventListener.expectMsgType[ChannelAborted] // test starts here @@ -268,11 +255,9 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(bob.stateName == CLOSING) bob2alice.expectMsgType[Error] val commitTx = bob2blockchain.expectFinalTxPublished("commit-tx").tx - val claimLocalAnchor = bob2blockchain.expectReplaceableTxPublished[ClaimLocalAnchorTx] val claimMain = bob2blockchain.expectFinalTxPublished("local-main-delayed").tx bob2blockchain.expectWatchTxConfirmed(commitTx.txid) bob2blockchain.expectWatchOutputSpent(claimMain.txIn.head.outPoint) - bob2blockchain.expectWatchOutputSpent(claimLocalAnchor.tx.txIn.head.outPoint) eventListener.expectMsgType[ChannelAborted] // test starts here @@ -310,7 +295,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with def testMutualCloseBeforeConverge(f: FixtureParam, commitmentFormat: CommitmentFormat): Unit = { import f._ val sender = TestProbe() - //assert(alice.commitments.latest.commitmentFormat == commitmentFormat) + assert(alice.commitments.latest.commitmentFormat == commitmentFormat) bob.setBitcoinCoreFeerates(FeeratesPerKw.single(FeeratePerKw(2500 sat)).copy(minimum = FeeratePerKw(250 sat), slow = FeeratePerKw(250 sat))) // alice initiates a closing with a low fee alice ! CMD_CLOSE(sender.ref, None, Some(ClosingFeerates(FeeratePerKw(500 sat), FeeratePerKw(250 sat), FeeratePerKw(1000 sat)))) @@ -445,6 +430,10 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with extractPreimageFromClaimHtlcSuccess(f) } + test("recv WatchOutputSpentTriggered (extract preimage from Claim-HTLC-success tx, taproot)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + extractPreimageFromClaimHtlcSuccess(f) + } + private def extractPreimageFromHtlcSuccess(f: FixtureParam): Unit = { import f._ @@ -485,6 +474,10 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with extractPreimageFromHtlcSuccess(f) } + test("recv WatchOutputSpentTriggered (extract preimage from HTLC-success tx, taproot)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + extractPreimageFromHtlcSuccess(f) + } + private def extractPreimageFromRemovedHtlc(f: FixtureParam): Unit = { import f._ @@ -570,6 +563,10 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with extractPreimageFromRemovedHtlc(f) } + test("recv WatchOutputSpentTriggered (extract preimage for removed HTLC, taproot)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + extractPreimageFromRemovedHtlc(f) + } + private def extractPreimageFromNextHtlcs(f: FixtureParam): Unit = { import f._ @@ -663,6 +660,10 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with extractPreimageFromNextHtlcs(f) } + test("recv WatchOutputSpentTriggered (extract preimage for next batch of HTLCs, taproot)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + extractPreimageFromNextHtlcs(f) + } + test("recv CMD_BUMP_FORCE_CLOSE_FEE (local commit)", Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => import f._ @@ -684,7 +685,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with def testLocalCommitTxConfirmed(f: FixtureParam, commitmentFormat: CommitmentFormat): Unit = { import f._ - // assert(alice.commitments.latest.commitmentFormat == commitmentFormat) + assert(alice.commitments.latest.commitmentFormat == commitmentFormat) val listener = TestProbe() systemA.eventStream.subscribe(listener.ref, classOf[LocalCommitConfirmed]) @@ -745,6 +746,10 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with testLocalCommitTxConfirmed(f, UnsafeLegacyAnchorOutputsCommitmentFormat) } + test("recv WatchTxConfirmedTriggered (local commit, taproot)", Tag(ChannelStateTestsTags.OptionSimpleTaprootPhoenix)) { f => + testLocalCommitTxConfirmed(f, PhoenixSimpleTaprootChannelCommitmentFormat) + } + test("recv WatchTxConfirmedTriggered (local commit with multiple htlcs for the same payment)") { f => import f._ @@ -894,7 +899,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with crossSign(bob, alice, bob2alice, alice2bob) assert(alice2relayer.expectMsgType[RelayForward].add == htlc) val aliceCommitTx = alice.signCommitTx() - assert(aliceCommitTx.txOut.size == 5) // 2 main outputs + 2 anchor outputs + 1 htlc + assert(aliceCommitTx.txOut.size == 3) // 2 main outputs + 1 htlc // alice fulfills the HTLC but bob doesn't receive the signature alice ! CMD_FULFILL_HTLC(htlc.id, r, None, commit = true) @@ -930,7 +935,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // note that alice doesn't receive the last revocation // then we make alice unilaterally close the channel val (closingState, closingTxs) = localClose(alice, alice2blockchain) - assert(closingState.commitTx.txOut.length == 4) // htlc has been removed + assert(closingState.commitTx.txOut.length == 2) // htlc has been removed // actual test starts here channelUpdateListener.expectMsgType[LocalChannelDown] @@ -1039,15 +1044,13 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // then we should re-publish unconfirmed transactions alice2blockchain.expectFinalTxPublished(closingState.commitTx.txid) // we increase the feerate of our main transaction, but cap it to our max-closing-feerate - val claimAnchor = alice2blockchain.expectReplaceableTxPublished[ClaimLocalAnchorTx] val mainTx2 = closingTxs.mainTx_opt.map(_ => alice2blockchain.expectFinalTxPublished("local-main-delayed")).get assert(mainTx2.tx.txOut.head.amount < closingTxs.mainTx_opt.get.txOut.head.amount) val mainFeerate = Transactions.fee2rate(mainTx2.fee, mainTx2.tx.weight()) assert(FeeratePerKw(14_500 sat) <= mainFeerate && mainFeerate <= FeeratePerKw(15_500 sat)) - assert(alice2blockchain.expectReplaceableTxPublished[HtlcTimeoutTx].input.outPoint == htlcTimeoutTx.txIn.head.outPoint) + assert(alice2blockchain.expectFinalTxPublished("htlc-timeout").input == htlcTimeoutTx.txIn.head.outPoint) alice2blockchain.expectWatchTxConfirmed(closingState.commitTx.txid) closingTxs.mainTx_opt.foreach(tx => alice2blockchain.expectWatchOutputSpent(tx.txIn.head.outPoint)) - alice2blockchain.expectWatchOutputSpent(claimAnchor.tx.txIn.head.outPoint) alice2blockchain.expectWatchOutputSpent(htlcTimeoutTx.txIn.head.outPoint) // the htlc transaction confirms, so we publish a 3rd-stage transaction @@ -1069,7 +1072,6 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with alice2blockchain.expectFinalTxPublished("local-main-delayed") alice2blockchain.expectWatchOutputSpent(mainTx.txIn.head.outPoint) }) - alice2blockchain.expectWatchOutputSpent(claimAnchor.tx.txIn.head.outPoint) assert(alice2blockchain.expectFinalTxPublished("htlc-delayed").input == htlcDelayed.input) alice2blockchain.expectWatchOutputSpent(htlcDelayed.input) // the main transaction confirms @@ -1290,10 +1292,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // actual test starts here channelUpdateListener.expectMsgType[LocalChannelDown] - if (alice.stateData.asInstanceOf[DATA_CLOSING].commitments.latest.commitmentFormat == DefaultCommitmentFormat) { - // skipped because it sends directly to the node's bitcoin wallet - assert(closingState.localOutput_opt.isEmpty) - } + assert(closingState.localOutput_opt.isEmpty) assert(closingState.htlcOutputs.isEmpty) // when the commit tx is signed, alice knows that the htlc she sent right before the unilateral close will never reach the chain alice ! WatchTxConfirmedTriggered(BlockHeight(0), 0, bobCommitTx) @@ -1355,14 +1354,11 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val initialState = alice.stateData.asInstanceOf[DATA_CLOSING] // bob publishes his last current commit tx, the one it had when entering NEGOTIATING state val bobCommitTx = bobCommitTxs.last - assert(bobCommitTx.txOut.size == 4) // two main outputs + assert(bobCommitTx.txOut.size == 2) // two main outputs val (closingState, closingTxs) = remoteClose(bobCommitTx, alice, alice2blockchain) - if (alice.stateData.asInstanceOf[DATA_CLOSING].commitments.latest.commitmentFormat == DefaultCommitmentFormat) { - // skipped because it sends directly to the node's bitcoin wallet - assert(closingState.localOutput_opt.isEmpty) - assert(closingTxs.mainTx_opt.isEmpty) - } + assert(closingState.localOutput_opt.isEmpty) assert(closingState.htlcOutputs.isEmpty) + assert(closingTxs.mainTx_opt.isEmpty) assert(alice.stateData.asInstanceOf[DATA_CLOSING].copy(remoteCommitPublished = None) == initialState) val txPublished = txListener.expectMsgType[TransactionPublished] assert(txPublished.tx == bobCommitTx) @@ -1389,10 +1385,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val bobCommitTx = bobCommitTxs.last val (closingState, closingTxs) = remoteClose(bobCommitTx, alice, alice2blockchain) assert(closingState.htlcOutputs.isEmpty) - if (alice.stateData.asInstanceOf[DATA_CLOSING].commitments.latest.commitmentFormat == DefaultCommitmentFormat) { - // skipped because it sends directly to the node's bitcoin wallet - assert(closingTxs.mainTx_opt.isEmpty) - } + assert(closingTxs.mainTx_opt.isEmpty) val txPublished = txListener.expectMsgType[TransactionPublished] assert(txPublished.tx == bobCommitTx) assert(txPublished.miningFee > 0.sat) // alice is funder, she pays the fee for the remote commit @@ -1439,12 +1432,11 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with test("recv WatchTxConfirmedTriggered (remote commit, option_static_remotekey)", Tag(ChannelStateTestsTags.StaticRemoteKey), Tag(ChannelStateTestsTags.SimpleClose)) { f => import f._ - assume(f.alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.commitmentFormat == DefaultCommitmentFormat) mutualClose(alice, bob, alice2bob, bob2alice, alice2blockchain, bob2blockchain) - // assert(alice.commitments.latest.commitmentFormat == DefaultCommitmentFormat) + assert(alice.commitments.latest.commitmentFormat == DefaultCommitmentFormat) // bob publishes his last current commit tx, the one it had when entering NEGOTIATING state val bobCommitTx = bobCommitTxs.last - assert(bobCommitTx.txOut.size == 4) // two main outputs + two anchor outputs + assert(bobCommitTx.txOut.size == 2) // two main outputs alice ! WatchFundingSpentTriggered(bobCommitTx) // alice won't create a claimMainOutputTx because her main output is already spendable by the wallet @@ -1459,7 +1451,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with import f._ mutualClose(alice, bob, alice2bob, bob2alice, alice2blockchain, bob2blockchain) val initialState = alice.stateData.asInstanceOf[DATA_CLOSING] - // assert(initialState.commitments.latest.commitmentFormat == ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) + assert(initialState.commitments.latest.commitmentFormat == ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) // bob publishes his last current commit tx, the one it had when entering NEGOTIATING state val bobCommitTx = bobCommitTxs.last assert(bobCommitTx.txOut.size == 4) // two main outputs + two anchors @@ -1478,7 +1470,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with def testRemoteCommitTxWithHtlcsConfirmed(f: FixtureParam, commitmentFormat: CommitmentFormat): Unit = { import f._ - // assert(alice.commitments.latest.commitmentFormat == commitmentFormat) + assert(alice.commitments.latest.commitmentFormat == commitmentFormat) // alice sends a first htlc to bob val (ra1, htlca1) = addHtlc(15_000_000 msat, alice, bob, alice2bob, bob2alice) @@ -1491,7 +1483,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // Bob publishes the latest commit tx. val bobCommitTx = bob.signCommitTx() - bob.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.commitmentFormat match { + commitmentFormat match { case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => assert(bobCommitTx.txOut.length == 7) // two main outputs + two anchors + 3 HTLCs case DefaultCommitmentFormat => assert(bobCommitTx.txOut.length == 5) // two main outputs + 3 HTLCs } @@ -1528,6 +1520,10 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with testRemoteCommitTxWithHtlcsConfirmed(f, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) } + test("recv WatchTxConfirmedTriggered (remote commit with multiple htlcs for the same payment, taproot)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + testRemoteCommitTxWithHtlcsConfirmed(f, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) + } + test("recv WatchTxConfirmedTriggered (remote commit) followed by htlc settlement", Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => import f._ // Bob sends 2 HTLCs to Alice that will be settled during the force-close: one will be fulfilled, the other will be failed. @@ -1585,7 +1581,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with test("recv INPUT_RESTORED (remote commit)") { f => import f._ - assume(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.commitmentFormat == DefaultCommitmentFormat) + // Alice sends an htlc to Bob: Bob then force-closes. val (_, htlc) = addHtlc(50_000_000 msat, CltvExpiryDelta(24), alice, bob, alice2bob, bob2alice) crossSign(alice, bob, alice2bob, bob2alice) @@ -1620,7 +1616,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with private def testNextRemoteCommitTxConfirmed(f: FixtureParam, commitmentFormat: CommitmentFormat): (Transaction, PublishedForceCloseTxs, Set[UpdateAddHtlc]) = { import f._ - // assert(alice.commitments.latest.commitmentFormat == commitmentFormat) + assert(alice.commitments.latest.commitmentFormat == commitmentFormat) // alice sends a first htlc to bob val (ra1, htlca1) = addHtlc(15_000_000 msat, alice, bob, alice2bob, bob2alice) @@ -1639,7 +1635,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // Bob publishes the next commit tx. val bobCommitTx = bob.signCommitTx() - bob.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.commitmentFormat match { + commitmentFormat match { case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => assert(bobCommitTx.txOut.length == 7) // two main outputs + two anchors + 3 HTLCs case DefaultCommitmentFormat => assert(bobCommitTx.txOut.length == 5) // two main outputs + 3 HTLCs } @@ -1674,7 +1670,6 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with test("recv WatchTxConfirmedTriggered (next remote commit, static_remotekey)", Tag(ChannelStateTestsTags.StaticRemoteKey)) { f => import f._ - assume(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.commitmentFormat == DefaultCommitmentFormat) val (bobCommitTx, closingTxs, htlcs) = testNextRemoteCommitTxConfirmed(f, DefaultCommitmentFormat) alice ! WatchTxConfirmedTriggered(BlockHeight(42), 0, bobCommitTx) assert(closingTxs.mainTx_opt.isEmpty) // with static_remotekey we don't claim out main output @@ -1711,6 +1706,25 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(alice.stateName == CLOSED) } + test("recv WatchTxConfirmedTriggered (next remote commit, taproot)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + import f._ + val (bobCommitTx, closingTxs, htlcs) = testNextRemoteCommitTxConfirmed(f, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) + alice ! WatchTxConfirmedTriggered(BlockHeight(42), 0, bobCommitTx) + closingTxs.mainTx_opt.foreach(tx => alice ! WatchTxConfirmedTriggered(BlockHeight(45), 0, tx)) + alice2relayer.expectNoMessage(100 millis) + alice ! WatchTxConfirmedTriggered(BlockHeight(201), 0, closingTxs.htlcTimeoutTxs(0)) + val forwardedFail1 = alice2relayer.expectMsgType[RES_ADD_SETTLED[Origin, HtlcResult.OnChainFail]].htlc + alice2relayer.expectNoMessage(100 millis) + alice ! WatchTxConfirmedTriggered(BlockHeight(202), 0, closingTxs.htlcTimeoutTxs(1)) + val forwardedFail2 = alice2relayer.expectMsgType[RES_ADD_SETTLED[Origin, HtlcResult.OnChainFail]].htlc + alice2relayer.expectNoMessage(100 millis) + alice ! WatchTxConfirmedTriggered(BlockHeight(203), 1, closingTxs.htlcTimeoutTxs(2)) + val forwardedFail3 = alice2relayer.expectMsgType[RES_ADD_SETTLED[Origin, HtlcResult.OnChainFail]].htlc + assert(Set(forwardedFail1, forwardedFail2, forwardedFail3) == htlcs) + alice2relayer.expectNoMessage(100 millis) + awaitCond(alice.stateName == CLOSED) + } + test("recv WatchTxConfirmedTriggered (next remote commit) followed by htlc settlement", Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => import f._ // Bob sends 2 HTLCs to Alice that will be settled during the force-close: one will be fulfilled, the other will be failed. @@ -1802,7 +1816,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with private def testFutureRemoteCommitTxConfirmed(f: FixtureParam, commitmentFormat: CommitmentFormat): Transaction = { import f._ val oldStateData = alice.stateData - // assert(oldStateData.asInstanceOf[DATA_NORMAL].commitments.latest.commitmentFormat == commitmentFormat) + assert(oldStateData.asInstanceOf[DATA_NORMAL].commitments.latest.commitmentFormat == commitmentFormat) // This HTLC will be fulfilled. val (ra1, htlca1) = addHtlc(25_000_000 msat, alice, bob, alice2bob, bob2alice) // These 2 HTLCs should timeout on-chain, but since alice lost data, she won't be able to claim them. @@ -1836,7 +1850,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(alice.stateName == WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT) // bob is nice and publishes its commitment val bobCommitTx = bob.signCommitTx() - alice.stateData.asInstanceOf[DATA_WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT].commitments.latest.commitmentFormat match { + commitmentFormat match { case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => assert(bobCommitTx.txOut.length == 6) // two main outputs + two anchors + 2 HTLCs case DefaultCommitmentFormat => assert(bobCommitTx.txOut.length == 4) // two main outputs + 2 HTLCs } @@ -1846,7 +1860,6 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with test("recv WatchTxConfirmedTriggered (future remote commit)") { f => import f._ - assume(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.commitmentFormat == DefaultCommitmentFormat) val bobCommitTx = testFutureRemoteCommitTxConfirmed(f, DefaultCommitmentFormat) val txPublished = txListener.expectMsgType[TransactionPublished] assert(txPublished.tx == bobCommitTx) @@ -1864,7 +1877,6 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with test("recv WatchTxConfirmedTriggered (future remote commit, option_static_remotekey)", Tag(ChannelStateTestsTags.StaticRemoteKey)) { f => import f._ - assume(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.commitmentFormat == DefaultCommitmentFormat) val bobCommitTx = testFutureRemoteCommitTxConfirmed(f, DefaultCommitmentFormat) // using option_static_remotekey alice doesn't need to sweep her output awaitCond(alice.stateName == CLOSING) @@ -1890,9 +1902,25 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(alice.stateName == CLOSED) } + test("recv WatchTxConfirmedTriggered (future remote commit, taproot)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + import f._ + val bobCommitTx = testFutureRemoteCommitTxConfirmed(f, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) + // alice is able to claim its main output + val mainTx = alice2blockchain.expectFinalTxPublished("remote-main-delayed") + Transaction.correctlySpends(mainTx.tx, bobCommitTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + alice2blockchain.expectWatchTxConfirmed(bobCommitTx.txid) + alice2blockchain.expectWatchOutputSpent(mainTx.input) + alice2blockchain.expectNoMessage(100 millis) // alice ignores the htlc-timeout + awaitCond(alice.stateData.asInstanceOf[DATA_CLOSING].futureRemoteCommitPublished.isDefined) + + // actual test starts here + alice ! WatchTxConfirmedTriggered(BlockHeight(0), 0, bobCommitTx) + alice ! WatchTxConfirmedTriggered(BlockHeight(0), 0, mainTx.tx) + awaitCond(alice.stateName == CLOSED) + } + test("recv INPUT_RESTORED (future remote commit)") { f => import f._ - assume(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.commitmentFormat == DefaultCommitmentFormat) val bobCommitTx = testFutureRemoteCommitTxConfirmed(f, DefaultCommitmentFormat) // simulate a node restart @@ -1916,7 +1944,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // Bob's first commit tx doesn't contain any htlc val bobCommit1 = RevokedCommit(bob.signCommitTx(), Nil) - alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.commitmentFormat match { + commitmentFormat match { case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => assert(bobCommit1.commitTx.txOut.size == 4) // 2 main outputs + 2 anchors case DefaultCommitmentFormat => assert(bobCommit1.commitTx.txOut.size == 2) // 2 main outputs } @@ -1932,7 +1960,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with } assert(alice.signCommitTx().txOut.size == bobCommit2.commitTx.txOut.size) - alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.commitmentFormat match { + commitmentFormat match { case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => assert(bobCommit2.commitTx.txOut.size == 6) case DefaultCommitmentFormat => assert(bobCommit2.commitTx.txOut.size == 4) } @@ -1948,7 +1976,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with } assert(alice.signCommitTx().txOut.size == bobCommit3.commitTx.txOut.size) - alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.commitmentFormat match { + commitmentFormat match { case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => assert(bobCommit3.commitTx.txOut.size == 8) case DefaultCommitmentFormat => assert(bobCommit3.commitTx.txOut.size == 6) } @@ -1962,7 +1990,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with } assert(alice.signCommitTx().txOut.size == bobCommit4.commitTx.txOut.size) - alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.commitmentFormat match { + commitmentFormat match { case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => assert(bobCommit4.commitTx.txOut.size == 4) case DefaultCommitmentFormat => assert(bobCommit4.commitTx.txOut.size == 2) } @@ -1976,7 +2004,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with import f._ val revokedCloseFixture = prepareRevokedClose(f, commitmentFormat) - // assert(alice.commitments.latest.commitmentFormat == commitmentFormat) + assert(alice.commitments.latest.commitmentFormat == commitmentFormat) // bob publishes one of his revoked txs val bobRevokedTx = revokedCloseFixture.bobRevokedTxs(1).commitTx @@ -2037,7 +2065,6 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with } test("recv WatchFundingSpentTriggered (one revoked tx, option_static_remotekey)", Tag(ChannelStateTestsTags.StaticRemoteKey)) { f => - assume(f.alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.commitmentFormat == DefaultCommitmentFormat) testFundingSpentRevokedTx(f, DefaultCommitmentFormat) } @@ -2049,9 +2076,13 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with testFundingSpentRevokedTx(f, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) } - test("recv WatchFundingSpentTriggered (multiple revoked tx)") { f => + test("recv WatchFundingSpentTriggered (one revoked tx, taproot)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + testFundingSpentRevokedTx(f, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) + } + + test("recv WatchFundingSpentTriggered (multiple revoked tx)", Tag(ChannelStateTestsTags.OptionSimpleTaprootPhoenix)) { f => import f._ - val revokedCloseFixture = prepareRevokedClose(f, alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.commitmentFormat) + val revokedCloseFixture = prepareRevokedClose(f, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) assert(revokedCloseFixture.bobRevokedTxs.map(_.commitTx.txid).toSet.size == revokedCloseFixture.bobRevokedTxs.size) // all commit txs are distinct def broadcastBobRevokedTx(revokedTx: Transaction, htlcCount: Int, revokedCount: Int): RevokedCloseTxs = { @@ -2061,15 +2092,15 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(alice.stateData.asInstanceOf[DATA_CLOSING].revokedCommitPublished.last.commitTx == revokedTx) // alice publishes penalty txs - val mainRemote = alice2blockchain.expectFinalTxPublished("remote-main-delayed") + val mainTx = alice2blockchain.expectFinalTxPublished("remote-main-delayed") val mainPenalty = alice2blockchain.expectFinalTxPublished("main-penalty") val htlcPenaltyTxs = (1 to htlcCount).map(_ => alice2blockchain.expectFinalTxPublished("htlc-penalty")) - (mainPenalty.tx +: htlcPenaltyTxs.map(_.tx)).foreach(tx => Transaction.correctlySpends(tx, revokedTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)) + (mainTx.tx +: mainPenalty.tx +: htlcPenaltyTxs.map(_.tx)).foreach(tx => Transaction.correctlySpends(tx, revokedTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)) alice2blockchain.expectWatchTxConfirmed(revokedTx.txid) - alice2blockchain.expectWatchOutputsSpent(mainPenalty.input +: (mainRemote.input +: htlcPenaltyTxs.map(_.input))) + alice2blockchain.expectWatchOutputsSpent(mainTx.input +: mainPenalty.input +: htlcPenaltyTxs.map(_.input)) alice2blockchain.expectNoMessage(100 millis) - RevokedCloseTxs(Some(mainRemote.tx), mainPenalty.tx, htlcPenaltyTxs.map(_.tx)) + RevokedCloseTxs(Some(mainTx.tx), mainPenalty.tx, htlcPenaltyTxs.map(_.tx)) } // bob publishes a first revoked tx (no htlc in that commitment) @@ -2082,9 +2113,9 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // bob's second revoked tx confirms: once all penalty txs are confirmed, alice can move to the closed state // NB: if multiple txs confirm in the same block, we may receive the events in any order alice ! WatchTxConfirmedTriggered(BlockHeight(100), 1, closingTxs.mainPenaltyTx) + alice ! WatchTxConfirmedTriggered(BlockHeight(100), 2, closingTxs.mainTx_opt.get) alice ! WatchTxConfirmedTriggered(BlockHeight(100), 3, revokedCloseFixture.bobRevokedTxs(1).commitTx) alice ! WatchTxConfirmedTriggered(BlockHeight(115), 0, closingTxs.htlcPenaltyTxs(0)) - closingTxs.mainTx_opt.foreach(tx => alice ! WatchTxConfirmedTriggered(BlockHeight(115), 0, tx)) assert(alice.stateName == CLOSING) alice ! WatchTxConfirmedTriggered(BlockHeight(115), 2, closingTxs.htlcPenaltyTxs(1)) awaitCond(alice.stateName == CLOSED) @@ -2116,7 +2147,6 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with } test("recv INPUT_RESTORED (one revoked tx)") { f => - assume(f.alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.commitmentFormat == DefaultCommitmentFormat) testInputRestoredRevokedTx(f, DefaultCommitmentFormat) } @@ -2128,10 +2158,14 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with testInputRestoredRevokedTx(f, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) } + test("recv INPUT_RESTORED (one revoked tx, taproot)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + testInputRestoredRevokedTx(f, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) + } + def testRevokedHtlcTxConfirmed(f: FixtureParam, commitmentFormat: CommitmentFormat): Unit = { import f._ - val revokedCloseFixture = prepareRevokedClose(f, f.alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.commitmentFormat) - // assert(alice.commitments.latest.commitmentFormat == commitmentFormat) + val revokedCloseFixture = prepareRevokedClose(f, commitmentFormat) + assert(alice.commitments.latest.commitmentFormat == commitmentFormat) // bob publishes one of his revoked txs val bobRevokedCommit = revokedCloseFixture.bobRevokedTxs(2) @@ -2223,7 +2257,11 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with testRevokedHtlcTxConfirmed(f, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) } - test("recv WatchTxConfirmedTriggered (revoked aggregated htlc tx)", Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + test("recv WatchTxConfirmedTriggered (revoked htlc-success tx, taproot)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + testRevokedHtlcTxConfirmed(f, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) + } + + def testRevokedAggregatedHtlcTxConfirmed(f: FixtureParam, commitmentFormat: CommitmentFormat): Unit = { import f._ // bob publishes one of his revoked txs @@ -2231,7 +2269,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val bobRevokedCommit = revokedCloseFixture.bobRevokedTxs(2) alice ! WatchFundingSpentTriggered(bobRevokedCommit.commitTx) awaitCond(alice.stateData.isInstanceOf[DATA_CLOSING]) - // assert(alice.commitments.latest.commitmentFormat == ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) + assert(alice.commitments.latest.commitmentFormat == commitmentFormat) awaitCond(alice.stateData.asInstanceOf[DATA_CLOSING].revokedCommitPublished.size == 1) val rvk = alice.stateData.asInstanceOf[DATA_CLOSING].revokedCommitPublished.head assert(rvk.commitTx == bobRevokedCommit.commitTx) @@ -2290,7 +2328,15 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with alice2blockchain.expectNoMessage(100 millis) } - test("recv INPUT_RESTORED (revoked htlc transactions confirmed)", Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + test("recv WatchTxConfirmedTriggered (revoked aggregated htlc tx)", Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + testRevokedAggregatedHtlcTxConfirmed(f, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) + } + + test("recv WatchTxConfirmedTriggered (revoked aggregated htlc tx, taproot)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + testRevokedAggregatedHtlcTxConfirmed(f, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) + } + + def testInputRestoredRevokedHtlcTxConfirmed(f: FixtureParam, commitmentFormat: CommitmentFormat): Unit = { import f._ // Bob publishes one of his revoked txs. @@ -2300,6 +2346,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val commitTx = bobRevokedCommit.commitTx alice ! WatchFundingSpentTriggered(commitTx) awaitCond(alice.stateData.isInstanceOf[DATA_CLOSING]) + assert(alice.commitments.latest.commitmentFormat == commitmentFormat) // Alice publishes the penalty txs and watches outputs. val mainTx = alice2blockchain.expectFinalTxPublished("remote-main-delayed") @@ -2395,8 +2442,17 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(alice.stateName == CLOSED) } + test("recv INPUT_RESTORED (revoked htlc transactions confirmed)", Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + testInputRestoredRevokedHtlcTxConfirmed(f, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) + } + + test("recv INPUT_RESTORED (revoked htlc transactions confirmed, taproot)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + testInputRestoredRevokedHtlcTxConfirmed(f, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) + } + private def testRevokedTxConfirmed(f: FixtureParam, commitmentFormat: CommitmentFormat): Unit = { import f._ + assert(alice.commitments.latest.commitmentFormat == commitmentFormat) val initOutputCount = commitmentFormat match { case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => 4 case DefaultCommitmentFormat => 2 @@ -2444,7 +2500,6 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with } test("recv WatchTxConfirmedTriggered (revoked commit tx, pending htlcs)") { f => - assume(f.alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.commitmentFormat == DefaultCommitmentFormat) testRevokedTxConfirmed(f, DefaultCommitmentFormat) } @@ -2456,6 +2511,10 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with testRevokedTxConfirmed(f, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) } + test("recv WatchTxConfirmedTriggered (revoked commit tx, pending htlcs, taproot)", Tag(ChannelStateTestsTags.OptionSimpleTaproot)) { f => + testRevokedTxConfirmed(f, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) + } + test("recv ChannelReestablish") { f => import f._ mutualClose(alice, bob, alice2bob, bob2alice, alice2blockchain, bob2blockchain) @@ -2485,11 +2544,3 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with } } - -class ClosingStateWithTaprootChannelsSpec extends ClosingStateSpec { - override val extraTags: Set[String] = Set(ChannelStateTestsTags.OptionSimpleTaproot) -} - -class ClosingStateWithLegacyTaprootChannelsSpec extends ClosingStateSpec { - override val extraTags: Set[String] = Set(ChannelStateTestsTags.OptionSimpleTaprootPhoenix) -} \ No newline at end of file From 9b4e51d79d664d89806e685aa69c381bd8333806 Mon Sep 17 00:00:00 2001 From: t-bast Date: Tue, 12 Aug 2025 19:48:02 +0200 Subject: [PATCH 16/18] Only generate local nonce if remote nonce is provided --- .../scala/fr/acinq/eclair/channel/Commitments.scala | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala index be09c584e6..eb0f35e60d 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala @@ -211,12 +211,13 @@ case class RemoteCommit(index: Long, spec: CommitmentSpec, txId: TxId, remotePer val sig = remoteCommitTx.sign(fundingKey, remoteFundingPubKey) Right(CommitSig(channelParams.channelId, sig, htlcSigs.toList)) case _: SimpleTaprootChannelCommitmentFormat => - val localNonce = NonceGenerator.signingNonce(fundingKey.publicKey, remoteFundingPubKey, commitInput.outPoint.txid) remoteNonce_opt match { - case Some(remoteNonce) => remoteCommitTx.partialSign(fundingKey, remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteNonce)) match { - case Left(_) => Left(InvalidCommitNonce(channelParams.channelId, commitInput.outPoint.txid, index)) - case Right(psig) => Right(CommitSig(channelParams.channelId, psig, htlcSigs.toList, batchSize = 1)) - } + case Some(remoteNonce) => + val localNonce = NonceGenerator.signingNonce(fundingKey.publicKey, remoteFundingPubKey, commitInput.outPoint.txid) + remoteCommitTx.partialSign(fundingKey, remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteNonce)) match { + case Left(_) => Left(InvalidCommitNonce(channelParams.channelId, commitInput.outPoint.txid, index)) + case Right(psig) => Right(CommitSig(channelParams.channelId, psig, htlcSigs.toList, batchSize = 1)) + } case None => Left(MissingCommitNonce(channelParams.channelId, commitInput.outPoint.txid, index)) } } @@ -668,9 +669,9 @@ case class Commitment(fundingTxIndex: Long, val sig = commitmentFormat match { case _: SegwitV0CommitmentFormat => remoteCommitTx.sign(fundingKey, remoteFundingPubKey) case _: SimpleTaprootChannelCommitmentFormat => - val localNonce = NonceGenerator.signingNonce(fundingKey.publicKey, remoteFundingPubKey, fundingTxId) nextRemoteNonce_opt match { case Some(remoteNonce) => + val localNonce = NonceGenerator.signingNonce(fundingKey.publicKey, remoteFundingPubKey, fundingTxId) remoteCommitTx.partialSign(fundingKey, remoteFundingPubKey, localNonce, Seq(localNonce.publicNonce, remoteNonce)) match { case Left(_) => return Left(InvalidCommitNonce(params.channelId, fundingTxId, remoteCommit.index + 1)) case Right(psig) => psig From 9ec72d4c74449e3da40d90f817c78ed3462c8a79 Mon Sep 17 00:00:00 2001 From: Fabrice Drouin Date: Mon, 18 Aug 2025 10:51:43 +0200 Subject: [PATCH 17/18] Verify nonces on reconnection *after* we've pruned funding transactions (#3138) If we have not received their funding locked, we may have active commitments that our peer has already pruned and will not send a nonce for, and which we need to ignore when checking nonces sent in their `channel_reestablish` message. --- .../fr/acinq/eclair/channel/fsm/Channel.scala | 274 ++++++++++-------- .../states/e/NormalSplicesStateSpec.scala | 22 +- 2 files changed, 169 insertions(+), 127 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala index 7fee82e87c..51f89ad23e 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala @@ -1106,8 +1106,14 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall // We only support updating phoenix channels to taproot: we ignore other attempts at upgrading the // commitment format and will simply apply the previous commitment format. val nextCommitmentFormat = msg.channelType_opt match { - case Some(_: ChannelTypes.SimpleTaprootChannelsPhoenix) if parentCommitment.commitmentFormat == UnsafeLegacyAnchorOutputsCommitmentFormat => PhoenixSimpleTaprootChannelCommitmentFormat - case _ => parentCommitment.commitmentFormat + case Some(channelType: ChannelTypes.SimpleTaprootChannelsPhoenix) if parentCommitment.commitmentFormat == UnsafeLegacyAnchorOutputsCommitmentFormat => + log.info(s"accepting upgrade to $channelType during splice from commitment format ${parentCommitment.commitmentFormat}") + PhoenixSimpleTaprootChannelCommitmentFormat + case Some(channelType) => + log.info(s"rejecting upgrade to $channelType during splice from commitment format ${parentCommitment.commitmentFormat}") + parentCommitment.commitmentFormat + case _ => + parentCommitment.commitmentFormat } val spliceAck = SpliceAck(d.channelId, fundingContribution = willFund_opt.map(_.purchase.amount).getOrElse(0 sat), @@ -2650,131 +2656,37 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall } case Event(channelReestablish: ChannelReestablish, d: DATA_NORMAL) => - val pendingSplice_opt = d.spliceStatus match { - // Note that we only consider splices that are also pending for our peer: otherwise it means we have disconnected - // before they sent their commit_sig, in which case they will abort the splice attempt on reconnection. - case SpliceStatus.SpliceWaitingForSigs(signingSession) if channelReestablish.nextFundingTxId_opt.contains(signingSession.fundingTxId) => Some(signingSession) - case _ => None - } - Helpers.Syncing.checkCommitNonces(channelReestablish, d.commitments, pendingSplice_opt) match { - case Some(f) => handleLocalError(f, d, Some(channelReestablish)) - case None => - remoteNextCommitNonces = channelReestablish.nextCommitNonces - Syncing.checkSync(channelKeys, d.commitments, channelReestablish) match { - case syncFailure: SyncResult.Failure => - handleSyncFailure(channelReestablish, syncFailure, d) - case syncSuccess: SyncResult.Success => - var sendQueue = Queue.empty[LightningMessage] - // normal case, our data is up-to-date - - // re-send channel_ready and announcement_signatures if necessary - d.commitments.lastLocalLocked_opt match { - case None => () - // We only send channel_ready for initial funding transactions. - case Some(c) if c.fundingTxIndex != 0 => () - case Some(c) => - val remoteSpliceSupport = d.commitments.remoteChannelParams.initFeatures.hasFeature(Features.SplicePrototype) - // If our peer has not received our channel_ready, we retransmit it. - val notReceivedByRemote = remoteSpliceSupport && channelReestablish.yourLastFundingLocked_opt.isEmpty - // If next_local_commitment_number is 1 in both the channel_reestablish it sent and received, then the node - // MUST retransmit channel_ready, otherwise it MUST NOT - val notReceivedByRemoteLegacy = !remoteSpliceSupport && channelReestablish.nextLocalCommitmentNumber == 1 && c.localCommit.index == 0 - // If this is a public channel and we haven't announced the channel, we retransmit our channel_ready and - // will also send announcement_signatures. - val notAnnouncedYet = d.commitments.announceChannel && c.shortChannelId_opt.nonEmpty && d.lastAnnouncement_opt.isEmpty - if (notAnnouncedYet || notReceivedByRemote || notReceivedByRemoteLegacy) { - log.debug("re-sending channel_ready") - sendQueue = sendQueue :+ createChannelReady(d.aliases, d.commitments) - } - if (notAnnouncedYet) { - // The funding transaction is confirmed, so we've already sent our announcement_signatures. - // We haven't announced the channel yet, which means we haven't received our peer's announcement_signatures. - // We retransmit our announcement_signatures to let our peer know that we're ready to announce the channel. - val localAnnSigs = c.signAnnouncement(nodeParams, d.commitments.channelParams, channelKeys.fundingKey(c.fundingTxIndex)) - localAnnSigs.foreach(annSigs => { - announcementSigsSent += annSigs.shortChannelId - sendQueue = sendQueue :+ annSigs - }) - } - } - - // resume splice signing session if any - val spliceStatus1 = channelReestablish.nextFundingTxId_opt match { - case Some(fundingTxId) => - d.spliceStatus match { - case SpliceStatus.SpliceWaitingForSigs(signingSession) if signingSession.fundingTx.txId == fundingTxId => - if (channelReestablish.nextLocalCommitmentNumber == d.commitments.remoteCommitIndex) { - // They haven't received our commit_sig: we retransmit it. - // We're also waiting for signatures from them, and will send our tx_signatures once we receive them. - log.info("re-sending commit_sig for splice attempt with fundingTxIndex={} fundingTxId={}", signingSession.fundingTxIndex, signingSession.fundingTx.txId) - val fundingParams = signingSession.fundingParams - val remoteNonce_opt = channelReestablish.currentCommitNonce_opt - signingSession.remoteCommit.sign(d.commitments.channelParams, signingSession.remoteCommitParams, channelKeys, signingSession.fundingTxIndex, fundingParams.remoteFundingPubKey, signingSession.commitInput(channelKeys), fundingParams.commitmentFormat, remoteNonce_opt) match { - case Left(f) => sendQueue = sendQueue :+ Warning(d.channelId, f.getMessage) - case Right(commitSig) => sendQueue = sendQueue :+ commitSig - } - } - d.spliceStatus - case _ if d.commitments.latest.fundingTxId == fundingTxId => - d.commitments.latest.localFundingStatus match { - case dfu: LocalFundingStatus.DualFundedUnconfirmedFundingTx => - // We've already received their commit_sig and sent our tx_signatures. We retransmit our - // tx_signatures and our commit_sig if they haven't received it already. - if (channelReestablish.nextLocalCommitmentNumber == d.commitments.remoteCommitIndex) { - log.info("re-sending commit_sig and tx_signatures for fundingTxIndex={} fundingTxId={}", d.commitments.latest.fundingTxIndex, d.commitments.latest.fundingTxId) - val remoteNonce_opt = channelReestablish.currentCommitNonce_opt - d.commitments.latest.remoteCommit.sign(d.commitments.channelParams, d.commitments.latest.remoteCommitParams, channelKeys, d.commitments.latest.fundingTxIndex, d.commitments.latest.remoteFundingPubKey, d.commitments.latest.commitInput(channelKeys), d.commitments.latest.commitmentFormat, remoteNonce_opt) match { - case Left(f) => sendQueue = sendQueue :+ Warning(d.channelId, f.getMessage) - case Right(commitSig) => sendQueue = sendQueue :+ commitSig :+ dfu.sharedTx.localSigs - } - } else { - log.info("re-sending tx_signatures for fundingTxIndex={} fundingTxId={}", d.commitments.latest.fundingTxIndex, d.commitments.latest.fundingTxId) - sendQueue = sendQueue :+ dfu.sharedTx.localSigs - } - case fundingStatus => - // They have not received our tx_signatures, but they must have received our commit_sig, otherwise we would be in the case above. - log.info("re-sending tx_signatures for fundingTxIndex={} fundingTxId={} (already published or confirmed)", d.commitments.latest.fundingTxIndex, d.commitments.latest.fundingTxId) - sendQueue = sendQueue ++ fundingStatus.localSigs_opt.toSeq - } - d.spliceStatus - case _ => - // The fundingTxId must be for a splice attempt that we didn't store (we got disconnected before receiving - // their tx_complete): we tell them to abort that splice attempt. - log.info(s"aborting obsolete splice attempt for fundingTxId=$fundingTxId") - sendQueue = sendQueue :+ TxAbort(d.channelId, SpliceAttemptAborted(d.channelId).getMessage) - SpliceStatus.SpliceAborted - } - case None => d.spliceStatus - } + Syncing.checkSync(channelKeys, d.commitments, channelReestablish) match { + case syncFailure: SyncResult.Failure => + handleSyncFailure(channelReestablish, syncFailure, d) + case syncSuccess: SyncResult.Success => + // normal case, our data is up-to-date + var sendQueue = resendChannelReady(channelReestablish: ChannelReestablish, d) + val (spliceStatus1, sendQueue1) = resumeSigningSession(channelReestablish, d) + sendQueue = sendQueue ++ sendQueue1 + + // Prune previous funding transactions and RBF attempts if we already sent splice_locked for the last funding + // transaction that is also locked by our counterparty; we either missed their splice_locked or it confirmed + // while disconnected. + val commitments1: Commitments = channelReestablish.myCurrentFundingLocked_opt + .flatMap(remoteFundingTxLocked => d.commitments.updateRemoteFundingStatus(remoteFundingTxLocked, d.lastAnnouncedFundingTxId_opt).toOption.map(_._1)) + .getOrElse(d.commitments) + // We then clean up unsigned updates that haven't been received before the disconnection. + .discardUnsignedUpdates() + + val pendingSplice_opt = spliceStatus1 match { + // Note that we only consider splices that are also pending for our peer: otherwise it means we have disconnected + // before they sent their commit_sig, in which case they will abort the splice attempt on reconnection. + case SpliceStatus.SpliceWaitingForSigs(signingSession) if channelReestablish.nextFundingTxId_opt.contains(signingSession.fundingTxId) => Some(signingSession) + case _ => None + } - // Prune previous funding transactions and RBF attempts if we already sent splice_locked for the last funding - // transaction that is also locked by our counterparty; we either missed their splice_locked or it confirmed - // while disconnected. - val commitments1: Commitments = channelReestablish.myCurrentFundingLocked_opt - .flatMap(remoteFundingTxLocked => d.commitments.updateRemoteFundingStatus(remoteFundingTxLocked, d.lastAnnouncedFundingTxId_opt).toOption.map(_._1)) - .getOrElse(d.commitments) - // We then clean up unsigned updates that haven't been received before the disconnection. - .discardUnsignedUpdates() + Helpers.Syncing.checkCommitNonces(channelReestablish, commitments1, pendingSplice_opt) match { + case Some(f) => handleLocalError(f, d, Some(channelReestablish)) + case None => + remoteNextCommitNonces = channelReestablish.nextCommitNonces - commitments1.lastLocalLocked_opt match { - case None => () - // We only send splice_locked for splice transactions. - case Some(c) if c.fundingTxIndex == 0 => () - case Some(c) => - // If our peer has not received our splice_locked, we retransmit it. - val notReceivedByRemote = !channelReestablish.yourLastFundingLocked_opt.contains(c.fundingTxId) - // If this is a public channel and we haven't announced the splice, we retransmit our splice_locked and - // will exchange announcement_signatures afterwards. - val notAnnouncedYet = commitments1.announceChannel && d.lastAnnouncement_opt.forall(ann => !c.shortChannelId_opt.contains(ann.shortChannelId)) - if (notReceivedByRemote || notAnnouncedYet) { - // Retransmission of local announcement_signatures for splices are done when receiving splice_locked, no need - // to retransmit here. - log.debug("re-sending splice_locked for fundingTxId={}", c.fundingTxId) - spliceLockedSent += (c.fundingTxId -> c.fundingTxIndex) - trimSpliceLockedSentIfNeeded() - sendQueue = sendQueue :+ SpliceLocked(d.channelId, c.fundingTxId) - } - } + sendQueue = sendQueue ++ resendSpliceLocked(channelReestablish, commitments1, d.channelId, d.lastAnnouncement_opt) // we may need to retransmit updates and/or commit_sig and/or revocation sendQueue = sendQueue ++ syncSuccess.retransmit @@ -3491,6 +3403,118 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall } } + private def resendChannelReady(channelReestablish: ChannelReestablish, d: DATA_NORMAL): Queue[LightningMessage] = { + var sendQueue = Queue.empty[LightningMessage] + // re-send channel_ready and announcement_signatures if necessary + d.commitments.lastLocalLocked_opt match { + case None => () + // We only send channel_ready for initial funding transactions. + case Some(c) if c.fundingTxIndex != 0 => () + case Some(c) => + val remoteSpliceSupport = d.commitments.remoteChannelParams.initFeatures.hasFeature(Features.SplicePrototype) + // If our peer has not received our channel_ready, we retransmit it. + val notReceivedByRemote = remoteSpliceSupport && channelReestablish.yourLastFundingLocked_opt.isEmpty + // If next_local_commitment_number is 1 in both the channel_reestablish it sent and received, then the node + // MUST retransmit channel_ready, otherwise it MUST NOT + val notReceivedByRemoteLegacy = !remoteSpliceSupport && channelReestablish.nextLocalCommitmentNumber == 1 && c.localCommit.index == 0 + // If this is a public channel and we haven't announced the channel, we retransmit our channel_ready and + // will also send announcement_signatures. + val notAnnouncedYet = d.commitments.announceChannel && c.shortChannelId_opt.nonEmpty && d.lastAnnouncement_opt.isEmpty + if (notAnnouncedYet || notReceivedByRemote || notReceivedByRemoteLegacy) { + log.debug("re-sending channel_ready") + sendQueue = sendQueue :+ createChannelReady(d.aliases, d.commitments) + } + if (notAnnouncedYet) { + // The funding transaction is confirmed, so we've already sent our announcement_signatures. + // We haven't announced the channel yet, which means we haven't received our peer's announcement_signatures. + // We retransmit our announcement_signatures to let our peer know that we're ready to announce the channel. + val localAnnSigs = c.signAnnouncement(nodeParams, d.commitments.channelParams, channelKeys.fundingKey(c.fundingTxIndex)) + localAnnSigs.foreach(annSigs => { + announcementSigsSent += annSigs.shortChannelId + sendQueue = sendQueue :+ annSigs + }) + } + } + sendQueue + } + + private def resumeSigningSession(channelReestablish: ChannelReestablish, d: DATA_NORMAL): (SpliceStatus, Queue[LightningMessage]) = { + var sendQueue = Queue.empty[LightningMessage] + // resume splice signing session if any + val spliceStatus1 = channelReestablish.nextFundingTxId_opt match { + case Some(fundingTxId) => + d.spliceStatus match { + case SpliceStatus.SpliceWaitingForSigs(signingSession) if signingSession.fundingTx.txId == fundingTxId => + if (channelReestablish.nextLocalCommitmentNumber == d.commitments.remoteCommitIndex) { + // They haven't received our commit_sig: we retransmit it. + // We're also waiting for signatures from them, and will send our tx_signatures once we receive them. + log.info("re-sending commit_sig for splice attempt with fundingTxIndex={} fundingTxId={}", signingSession.fundingTxIndex, signingSession.fundingTx.txId) + val fundingParams = signingSession.fundingParams + val remoteNonce_opt = channelReestablish.currentCommitNonce_opt + signingSession.remoteCommit.sign(d.commitments.channelParams, signingSession.remoteCommitParams, channelKeys, signingSession.fundingTxIndex, fundingParams.remoteFundingPubKey, signingSession.commitInput(channelKeys), fundingParams.commitmentFormat, remoteNonce_opt) match { + case Left(f) => sendQueue = sendQueue :+ Warning(d.channelId, f.getMessage) + case Right(commitSig) => sendQueue = sendQueue :+ commitSig + } + } + d.spliceStatus + case _ if d.commitments.latest.fundingTxId == fundingTxId => + d.commitments.latest.localFundingStatus match { + case dfu: LocalFundingStatus.DualFundedUnconfirmedFundingTx => + // We've already received their commit_sig and sent our tx_signatures. We retransmit our + // tx_signatures and our commit_sig if they haven't received it already. + if (channelReestablish.nextLocalCommitmentNumber == d.commitments.remoteCommitIndex) { + log.info("re-sending commit_sig and tx_signatures for fundingTxIndex={} fundingTxId={}", d.commitments.latest.fundingTxIndex, d.commitments.latest.fundingTxId) + val remoteNonce_opt = channelReestablish.currentCommitNonce_opt + d.commitments.latest.remoteCommit.sign(d.commitments.channelParams, d.commitments.latest.remoteCommitParams, channelKeys, d.commitments.latest.fundingTxIndex, d.commitments.latest.remoteFundingPubKey, d.commitments.latest.commitInput(channelKeys), d.commitments.latest.commitmentFormat, remoteNonce_opt) match { + case Left(f) => sendQueue = sendQueue :+ Warning(d.channelId, f.getMessage) + case Right(commitSig) => sendQueue = sendQueue :+ commitSig :+ dfu.sharedTx.localSigs + } + } else { + log.info("re-sending tx_signatures for fundingTxIndex={} fundingTxId={}", d.commitments.latest.fundingTxIndex, d.commitments.latest.fundingTxId) + sendQueue = sendQueue :+ dfu.sharedTx.localSigs + } + case fundingStatus => + // They have not received our tx_signatures, but they must have received our commit_sig, otherwise we would be in the case above. + log.info("re-sending tx_signatures for fundingTxIndex={} fundingTxId={} (already published or confirmed)", d.commitments.latest.fundingTxIndex, d.commitments.latest.fundingTxId) + sendQueue = sendQueue ++ fundingStatus.localSigs_opt.toSeq + } + d.spliceStatus + case _ => + // The fundingTxId must be for a splice attempt that we didn't store (we got disconnected before receiving + // their tx_complete): we tell them to abort that splice attempt. + log.info(s"aborting obsolete splice attempt for fundingTxId=$fundingTxId") + sendQueue = sendQueue :+ TxAbort(d.channelId, SpliceAttemptAborted(d.channelId).getMessage) + SpliceStatus.SpliceAborted + } + case None => d.spliceStatus + } + (spliceStatus1, sendQueue) + } + + private def resendSpliceLocked(channelReestablish: ChannelReestablish, commitments: Commitments, channelId: ByteVector32, lastAnnouncement_opt: Option[ChannelAnnouncement]): Queue[LightningMessage] = { + var sendQueue = Queue.empty[LightningMessage] + commitments.lastLocalLocked_opt match { + case None => () + // We only send splice_locked for splice transactions. + case Some(c) if c.fundingTxIndex == 0 => () + case Some(c) => + // If our peer has not received our splice_locked, we retransmit it. + val notReceivedByRemote = !channelReestablish.yourLastFundingLocked_opt.contains(c.fundingTxId) + // If this is a public channel and we haven't announced the splice, we retransmit our splice_locked and + // will exchange announcement_signatures afterwards. + val notAnnouncedYet = commitments.announceChannel && lastAnnouncement_opt.forall(ann => !c.shortChannelId_opt.contains(ann.shortChannelId)) + if (notReceivedByRemote || notAnnouncedYet) { + // Retransmission of local announcement_signatures for splices are done when receiving splice_locked, no need + // to retransmit here. + log.debug("re-sending splice_locked for fundingTxId={}", c.fundingTxId) + spliceLockedSent += (c.fundingTxId -> c.fundingTxIndex) + trimSpliceLockedSentIfNeeded() + sendQueue = sendQueue :+ SpliceLocked(channelId, c.fundingTxId) + } + } + sendQueue + } + /** * Return full information about a known closing tx. */ diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala index 4dc750b41e..fc18f2ca9b 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala @@ -753,7 +753,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik resolveHtlcs(f, htlcs) } - test("recv CMD_SPLICE (upgrade channel to taproot)", Tag(ChannelStateTestsTags.AnchorOutputs)) { f => + test("recv CMD_SPLICE (accepting upgrade channel to taproot)", Tag(ChannelStateTestsTags.AnchorOutputs)) { f => import f._ val htlcs = setupHtlcs(f) @@ -763,6 +763,16 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik resolveHtlcs(f, htlcs) } + test("recv CMD_SPLICE (rejecting upgrade channel to taproot)", Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + import f._ + + val htlcs = setupHtlcs(f) + initiateSplice(f, spliceIn_opt = Some(SpliceIn(400_000 sat)), channelType_opt = Some(ChannelTypes.SimpleTaprootChannelsPhoenix())) + assert(alice.commitments.active.head.commitmentFormat == ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) + assert(alice.commitments.active.last.commitmentFormat == ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) + resolveHtlcs(f, htlcs) + } + test("recv CMD_BUMP_FUNDING_FEE (splice-in + splice-out)") { f => import f._ @@ -2607,7 +2617,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik alice2blockchain.expectWatchFundingSpent(fundingTx.txid) } - test("re-send splice_locked on reconnection") { f => + def resendSpliceLockedOnReconnection(f: FixtureParam): Unit = { import f._ val fundingTx1 = initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat, pushAmount = 0 msat))) @@ -2695,6 +2705,14 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik } } + test("re-send splice_locked on reconnection") { f => + resendSpliceLockedOnReconnection(f) + } + + test("re-send splice_locked on reconnection (taproot channels)", Tag(ChannelStateTestsTags.OptionSimpleTaprootPhoenix)) { f => + resendSpliceLockedOnReconnection(f) + } + test("disconnect before channel update and tx_signatures are received") { f => import f._ // Disconnection with both sides sending tx_signatures and channel updates From 5347f75d756e1d030108974b3a1f6d018b88434b Mon Sep 17 00:00:00 2001 From: t-bast Date: Mon, 18 Aug 2025 11:13:46 +0200 Subject: [PATCH 18/18] More refactoring of `channel_reestablish` We iterate on the refactoring from #3138 to further simplify the channel reestablish handler: we more explicitly return a single optional message from helper functions when possible, and move comments back to the handler instead of the helper functions. --- .../fr/acinq/eclair/channel/fsm/Channel.scala | 63 ++++++++++--------- 1 file changed, 33 insertions(+), 30 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala index 51f89ad23e..5b42cefcb0 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala @@ -2661,34 +2661,38 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall handleSyncFailure(channelReestablish, syncFailure, d) case syncSuccess: SyncResult.Success => // normal case, our data is up-to-date - var sendQueue = resendChannelReady(channelReestablish: ChannelReestablish, d) - val (spliceStatus1, sendQueue1) = resumeSigningSession(channelReestablish, d) - sendQueue = sendQueue ++ sendQueue1 + var sendQueue = Queue.empty[LightningMessage] + // We re-send channel_ready and announcement_signatures for the initial funding transaction if necessary. + val (channelReady_opt, announcementSigs_opt) = resendChannelReadyIfNeeded(channelReestablish, d) + sendQueue = sendQueue ++ channelReady_opt.toSeq ++ announcementSigs_opt.toSeq + // If we disconnected in the middle of a signing a splice transaction, we re-send our signatures or abort. + val (spliceStatus1, spliceMessages) = resumeSpliceSigningSessionIfNeeded(channelReestablish, d) + sendQueue = sendQueue ++ spliceMessages // Prune previous funding transactions and RBF attempts if we already sent splice_locked for the last funding // transaction that is also locked by our counterparty; we either missed their splice_locked or it confirmed // while disconnected. - val commitments1: Commitments = channelReestablish.myCurrentFundingLocked_opt + val commitments1 = channelReestablish.myCurrentFundingLocked_opt .flatMap(remoteFundingTxLocked => d.commitments.updateRemoteFundingStatus(remoteFundingTxLocked, d.lastAnnouncedFundingTxId_opt).toOption.map(_._1)) .getOrElse(d.commitments) // We then clean up unsigned updates that haven't been received before the disconnection. .discardUnsignedUpdates() + // If there is a pending splice, we need to receive nonces for the corresponding transaction if we're using taproot. val pendingSplice_opt = spliceStatus1 match { // Note that we only consider splices that are also pending for our peer: otherwise it means we have disconnected // before they sent their commit_sig, in which case they will abort the splice attempt on reconnection. case SpliceStatus.SpliceWaitingForSigs(signingSession) if channelReestablish.nextFundingTxId_opt.contains(signingSession.fundingTxId) => Some(signingSession) case _ => None } - Helpers.Syncing.checkCommitNonces(channelReestablish, commitments1, pendingSplice_opt) match { case Some(f) => handleLocalError(f, d, Some(channelReestablish)) case None => remoteNextCommitNonces = channelReestablish.nextCommitNonces - - sendQueue = sendQueue ++ resendSpliceLocked(channelReestablish, commitments1, d.channelId, d.lastAnnouncement_opt) - - // we may need to retransmit updates and/or commit_sig and/or revocation + // We re-send our latest splice_locked if needed. + val spliceLocked_opt = resendSpliceLockedIfNeeded(channelReestablish, commitments1, d.lastAnnouncement_opt) + sendQueue = sendQueue ++ spliceLocked_opt.toSeq + // We may need to retransmit updates and/or commit_sig and/or revocation to resume the channel. sendQueue = sendQueue ++ syncSuccess.retransmit commitments1.remoteNextCommitInfo match { @@ -3403,13 +3407,11 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall } } - private def resendChannelReady(channelReestablish: ChannelReestablish, d: DATA_NORMAL): Queue[LightningMessage] = { - var sendQueue = Queue.empty[LightningMessage] - // re-send channel_ready and announcement_signatures if necessary + private def resendChannelReadyIfNeeded(channelReestablish: ChannelReestablish, d: DATA_NORMAL): (Option[ChannelReady], Option[AnnouncementSignatures]) = { d.commitments.lastLocalLocked_opt match { - case None => () + case None => (None, None) // We only send channel_ready for initial funding transactions. - case Some(c) if c.fundingTxIndex != 0 => () + case Some(c) if c.fundingTxIndex != 0 => (None, None) case Some(c) => val remoteSpliceSupport = d.commitments.remoteChannelParams.initFeatures.hasFeature(Features.SplicePrototype) // If our peer has not received our channel_ready, we retransmit it. @@ -3420,27 +3422,28 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall // If this is a public channel and we haven't announced the channel, we retransmit our channel_ready and // will also send announcement_signatures. val notAnnouncedYet = d.commitments.announceChannel && c.shortChannelId_opt.nonEmpty && d.lastAnnouncement_opt.isEmpty - if (notAnnouncedYet || notReceivedByRemote || notReceivedByRemoteLegacy) { + val channelReady_opt = if (notAnnouncedYet || notReceivedByRemote || notReceivedByRemoteLegacy) { log.debug("re-sending channel_ready") - sendQueue = sendQueue :+ createChannelReady(d.aliases, d.commitments) + Some(createChannelReady(d.aliases, d.commitments)) + } else { + None } - if (notAnnouncedYet) { + val announcementSigs_opt = if (notAnnouncedYet) { // The funding transaction is confirmed, so we've already sent our announcement_signatures. // We haven't announced the channel yet, which means we haven't received our peer's announcement_signatures. // We retransmit our announcement_signatures to let our peer know that we're ready to announce the channel. val localAnnSigs = c.signAnnouncement(nodeParams, d.commitments.channelParams, channelKeys.fundingKey(c.fundingTxIndex)) - localAnnSigs.foreach(annSigs => { - announcementSigsSent += annSigs.shortChannelId - sendQueue = sendQueue :+ annSigs - }) + localAnnSigs.foreach(annSigs => announcementSigsSent += annSigs.shortChannelId) + localAnnSigs + } else { + None } + (channelReady_opt, announcementSigs_opt) } - sendQueue } - private def resumeSigningSession(channelReestablish: ChannelReestablish, d: DATA_NORMAL): (SpliceStatus, Queue[LightningMessage]) = { + private def resumeSpliceSigningSessionIfNeeded(channelReestablish: ChannelReestablish, d: DATA_NORMAL): (SpliceStatus, Queue[LightningMessage]) = { var sendQueue = Queue.empty[LightningMessage] - // resume splice signing session if any val spliceStatus1 = channelReestablish.nextFundingTxId_opt match { case Some(fundingTxId) => d.spliceStatus match { @@ -3491,12 +3494,11 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall (spliceStatus1, sendQueue) } - private def resendSpliceLocked(channelReestablish: ChannelReestablish, commitments: Commitments, channelId: ByteVector32, lastAnnouncement_opt: Option[ChannelAnnouncement]): Queue[LightningMessage] = { - var sendQueue = Queue.empty[LightningMessage] + private def resendSpliceLockedIfNeeded(channelReestablish: ChannelReestablish, commitments: Commitments, lastAnnouncement_opt: Option[ChannelAnnouncement]): Option[SpliceLocked] = { commitments.lastLocalLocked_opt match { - case None => () + case None => None // We only send splice_locked for splice transactions. - case Some(c) if c.fundingTxIndex == 0 => () + case Some(c) if c.fundingTxIndex == 0 => None case Some(c) => // If our peer has not received our splice_locked, we retransmit it. val notReceivedByRemote = !channelReestablish.yourLastFundingLocked_opt.contains(c.fundingTxId) @@ -3509,10 +3511,11 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall log.debug("re-sending splice_locked for fundingTxId={}", c.fundingTxId) spliceLockedSent += (c.fundingTxId -> c.fundingTxIndex) trimSpliceLockedSentIfNeeded() - sendQueue = sendQueue :+ SpliceLocked(channelId, c.fundingTxId) + Some(SpliceLocked(commitments.channelId, c.fundingTxId)) + } else { + None } } - sendQueue } /**