From ee8d3253ccb9b708c0dec26f541de7563c8da9ae Mon Sep 17 00:00:00 2001 From: Richard Myers Date: Thu, 15 Jun 2023 15:39:53 +0200 Subject: [PATCH] Update splice to handle pending committed htlcs If a channel has negotiated quiescence with pending htlcs then `InteractiveTxBuilder` will create/verify commit txs which include htlc outputs and create/verify htlc signatures when exchanging `CommitSig`. The new attribute `htlcsAmount` of `Output.Shared` and `Input.Shared` accounts for the value in htlcs when computing fees. --- .../fr/acinq/eclair/channel/Commitments.scala | 47 +- .../fr/acinq/eclair/channel/Helpers.scala | 51 +- .../fr/acinq/eclair/channel/fsm/Channel.scala | 5 +- .../channel/fund/InteractiveTxBuilder.scala | 60 +- .../channel/fund/InteractiveTxFunder.scala | 8 +- .../channel/version4/ChannelCodecs4.scala | 48 +- .../channel/InteractiveTxBuilderSpec.scala | 2 +- .../states/e/NormalSplicesStateSpec.scala | 694 ++++++++++++++---- .../eclair/testutils/PimpTestProbe.scala | 5 +- .../channel/version4/ChannelCodecs4Spec.scala | 2 +- 10 files changed, 678 insertions(+), 244 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 8da3805499..c69619d67c 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 @@ -236,6 +236,30 @@ case class CommitTxAndRemoteSig(commitTx: CommitTx, remoteSig: ByteVector64) /** The local commitment maps to a commitment transaction that we can sign and broadcast if necessary. */ case class LocalCommit(index: Long, spec: CommitmentSpec, commitTxAndRemoteSig: CommitTxAndRemoteSig, htlcTxsAndRemoteSigs: List[HtlcTxAndRemoteSig]) +object LocalCommit { + def fromCommitSig(keyManager: ChannelKeyManager, params: ChannelParams, fundingTxId: ByteVector32, + fundingTxIndex: Long, remoteFundingPubKey: PublicKey, commitInput: InputInfo, + commit: CommitSig, localCommitIndex: Long, spec: CommitmentSpec, localPerCommitmentPoint: PublicKey): Either[ChannelException, LocalCommit] = { + val (localCommitTx, htlcTxs) = Commitment.makeLocalTxs(keyManager, params.channelConfig, params.channelFeatures, localCommitIndex, params.localParams, params.remoteParams, fundingTxIndex, remoteFundingPubKey, commitInput, localPerCommitmentPoint, spec) + if (!checkSig(localCommitTx, commit.signature, remoteFundingPubKey, TxOwner.Remote, params.commitmentFormat)) { + return Left(InvalidCommitmentSignature(params.channelId, fundingTxId, fundingTxIndex, localCommitTx.tx)) + } + val sortedHtlcTxs = htlcTxs.sortBy(_.input.outPoint.index) + if (commit.htlcSignatures.size != sortedHtlcTxs.size) { + return Left(HtlcSigCountMismatch(params.channelId, sortedHtlcTxs.size, commit.htlcSignatures.size)) + } + val remoteHtlcPubkey = Generators.derivePubKey(params.remoteParams.htlcBasepoint, localPerCommitmentPoint) + val htlcTxsAndRemoteSigs = sortedHtlcTxs.zip(commit.htlcSignatures).toList.map { + case (htlcTx: HtlcTx, remoteSig) => + if (!checkSig(htlcTx, remoteSig, remoteHtlcPubkey, TxOwner.Remote, params.commitmentFormat)) { + return Left(InvalidHtlcSignature(params.channelId, htlcTx.tx.txid)) + } + HtlcTxAndRemoteSig(htlcTx, remoteSig) + } + Right(LocalCommit(localCommitIndex, spec, CommitTxAndRemoteSig(localCommitTx, commit.signature), htlcTxsAndRemoteSigs)) + } +} + /** The remote commitment maps to a commitment transaction that only our peer can sign and broadcast. */ case class RemoteCommit(index: Long, spec: CommitmentSpec, txid: ByteVector32, remotePerCommitmentPoint: PublicKey) { def sign(keyManager: ChannelKeyManager, params: ChannelParams, fundingTxIndex: Long, remoteFundingPubKey: PublicKey, commitInput: InputInfo): CommitSig = { @@ -619,27 +643,12 @@ case class Commitment(fundingTxIndex: Long, // ourCommit.index + 2 -> which is about to become our next revocation hash // we will reply to this sig with our old revocation hash preimage (at index) and our next revocation hash (at index + 1) // and will increment our index + val localCommitIndex = localCommit.index + 1 val spec = CommitmentSpec.reduce(localCommit.spec, changes.localChanges.acked, changes.remoteChanges.proposed) - val (localCommitTx, htlcTxs) = Commitment.makeLocalTxs(keyManager, params.channelConfig, params.channelFeatures, localCommit.index + 1, params.localParams, params.remoteParams, fundingTxIndex, remoteFundingPubKey, commitInput, localPerCommitmentPoint, spec) - log.info(s"built local commit number=${localCommit.index + 1} toLocalMsat=${spec.toLocal.toLong} toRemoteMsat=${spec.toRemote.toLong} htlc_in={} htlc_out={} feeratePerKw=${spec.commitTxFeerate} txid=${localCommitTx.tx.txid} fundingTxId=$fundingTxId", spec.htlcs.collect(DirectedHtlc.incoming).map(_.id).mkString(","), spec.htlcs.collect(DirectedHtlc.outgoing).map(_.id).mkString(",")) - if (!checkSig(localCommitTx, commit.signature, remoteFundingPubKey, TxOwner.Remote, params.commitmentFormat)) { - return Left(InvalidCommitmentSignature(params.channelId, fundingTxId, fundingTxIndex, localCommitTx.tx)) - } - val sortedHtlcTxs: Seq[HtlcTx] = htlcTxs.sortBy(_.input.outPoint.index) - if (commit.htlcSignatures.size != sortedHtlcTxs.size) { - return Left(HtlcSigCountMismatch(params.channelId, sortedHtlcTxs.size, commit.htlcSignatures.size)) - } - val remoteHtlcPubkey = Generators.derivePubKey(params.remoteParams.htlcBasepoint, localPerCommitmentPoint) - val htlcTxsAndRemoteSigs = sortedHtlcTxs.zip(commit.htlcSignatures).toList.map { - case (htlcTx: HtlcTx, remoteSig) => - if (!checkSig(htlcTx, remoteSig, remoteHtlcPubkey, TxOwner.Remote, params.commitmentFormat)) { - return Left(InvalidHtlcSignature(params.channelId, htlcTx.tx.txid)) - } - HtlcTxAndRemoteSig(htlcTx, remoteSig) + LocalCommit.fromCommitSig(keyManager, params, fundingTxId, fundingTxIndex, remoteFundingPubKey, commitInput, commit, localCommitIndex, spec, localPerCommitmentPoint).map { localCommit1 => + log.info(s"built local commit number=$localCommitIndex toLocalMsat=${spec.toLocal.toLong} toRemoteMsat=${spec.toRemote.toLong} htlc_in={} htlc_out={} feeratePerKw=${spec.commitTxFeerate} txid=${localCommit1.commitTxAndRemoteSig.commitTx.tx.txid} fundingTxId=$fundingTxId", spec.htlcs.collect(DirectedHtlc.incoming).map(_.id).mkString(","), spec.htlcs.collect(DirectedHtlc.outgoing).map(_.id).mkString(",")) + copy(localCommit = localCommit1) } - // update our commitment data - val localCommit1 = LocalCommit(localCommit.index + 1, spec, CommitTxAndRemoteSig(localCommitTx, commit.signature), htlcTxsAndRemoteSigs) - Right(copy(localCommit = localCommit1)) } /** Return a fully signed commit tx, that can be published as-is. */ 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 28dd1d0270..f5f710070a 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 @@ -357,7 +357,7 @@ object Helpers { /** * Creates both sides' first commitment transaction. * - * @return (localSpec, localTx, remoteSpec, remoteTx, fundingTxOutput) + * @return (localSpec, localTx, remoteSpec, remoteTx) */ def makeFirstCommitTxs(keyManager: ChannelKeyManager, params: ChannelParams, @@ -366,36 +366,43 @@ object Helpers { commitTxFeerate: FeeratePerKw, fundingTxHash: ByteVector32, fundingTxOutputIndex: Int, remoteFundingPubKey: PublicKey, - remoteFirstPerCommitmentPoint: PublicKey): Either[ChannelException, (CommitmentSpec, CommitTx, CommitmentSpec, CommitTx)] = - makeCommitTxsWithoutHtlcs(keyManager, params, + remoteFirstPerCommitmentPoint: PublicKey): Either[ChannelException, (CommitmentSpec, CommitTx, CommitmentSpec, CommitTx)] = { + makeCommitTxs(keyManager, params, fundingAmount = localFundingAmount + remoteFundingAmount, toLocal = localFundingAmount.toMilliSatoshi - localPushAmount + remotePushAmount, toRemote = remoteFundingAmount.toMilliSatoshi + localPushAmount - remotePushAmount, + localHtlcs = Set.empty, commitTxFeerate, fundingTxIndex = 0, - fundingTxHash, fundingTxOutputIndex, remoteFundingPubKey = remoteFundingPubKey, remotePerCommitmentPoint = remoteFirstPerCommitmentPoint, commitmentIndex = 0) + fundingTxHash, fundingTxOutputIndex, + remoteFundingPubKey = remoteFundingPubKey, remotePerCommitmentPoint = remoteFirstPerCommitmentPoint, + commitmentIndex = 0).map { + case (localSpec, localCommit, remoteSpec, remoteCommit, _) => (localSpec, localCommit, remoteSpec, remoteCommit) + } + } /** - * This creates commitment transactions for both sides at an arbitrary `commitmentIndex`. There are no htlcs, only - * local/remote balances are provided. + * This creates commitment transactions for both sides at an arbitrary `commitmentIndex` and with (optional) `htlc` + * outputs. This function should only be used when commitments are synchronized (local and remote htlcs match). */ - def makeCommitTxsWithoutHtlcs(keyManager: ChannelKeyManager, - params: ChannelParams, - fundingAmount: Satoshi, - toLocal: MilliSatoshi, toRemote: MilliSatoshi, - commitTxFeerate: FeeratePerKw, - fundingTxIndex: Long, - fundingTxHash: ByteVector32, fundingTxOutputIndex: Int, - remoteFundingPubKey: PublicKey, - remotePerCommitmentPoint: PublicKey, - commitmentIndex: Long): Either[ChannelException, (CommitmentSpec, CommitTx, CommitmentSpec, CommitTx)] = { + def makeCommitTxs(keyManager: ChannelKeyManager, + params: ChannelParams, + fundingAmount: Satoshi, + toLocal: MilliSatoshi, toRemote: MilliSatoshi, + localHtlcs: Set[DirectedHtlc], + commitTxFeerate: FeeratePerKw, + fundingTxIndex: Long, + fundingTxHash: ByteVector32, fundingTxOutputIndex: Int, + remoteFundingPubKey: PublicKey, + remotePerCommitmentPoint: PublicKey, + commitmentIndex: Long): Either[ChannelException, (CommitmentSpec, CommitTx, CommitmentSpec, CommitTx, Seq[HtlcTx])] = { import params._ - val localSpec = CommitmentSpec(Set.empty[DirectedHtlc], commitTxFeerate, toLocal = toLocal, toRemote = toRemote) - val remoteSpec = CommitmentSpec(Set.empty[DirectedHtlc], commitTxFeerate, toLocal = toRemote, toRemote = toLocal) + val localSpec = CommitmentSpec(localHtlcs, commitTxFeerate, toLocal = toLocal, toRemote = toRemote) + val remoteSpec = CommitmentSpec(localHtlcs.map(_.opposite), commitTxFeerate, toLocal = toRemote, toRemote = toLocal) if (!localParams.isInitiator) { // They initiated the channel open, therefore they pay the fee: we need to make sure they can afford it! - // Note that the reserve may not be always be met: we could be using dual funding with a large funding amount on + // Note that the reserve may not always be met: we could be using dual funding with a large funding amount on // our side and a small funding amount on their side. But we shouldn't care as long as they can pay the fees for // the commitment transaction. val fees = commitTxTotalCost(remoteParams.dustLimit, remoteSpec, channelFeatures.commitmentFormat) @@ -410,9 +417,9 @@ object Helpers { val commitmentInput = makeFundingInputInfo(fundingTxHash, fundingTxOutputIndex, fundingAmount, fundingPubKey.publicKey, remoteFundingPubKey) val localPerCommitmentPoint = keyManager.commitmentPoint(channelKeyPath, commitmentIndex) val (localCommitTx, _) = Commitment.makeLocalTxs(keyManager, channelConfig, channelFeatures, commitmentIndex, localParams, remoteParams, fundingTxIndex, remoteFundingPubKey, commitmentInput, localPerCommitmentPoint, localSpec) - val (remoteCommitTx, _) = Commitment.makeRemoteTxs(keyManager, channelConfig, channelFeatures, commitmentIndex, localParams, remoteParams, fundingTxIndex, remoteFundingPubKey, commitmentInput, remotePerCommitmentPoint, remoteSpec) - - Right(localSpec, localCommitTx, remoteSpec, remoteCommitTx) + val (remoteCommitTx, htlcTxs) = Commitment.makeRemoteTxs(keyManager, channelConfig, channelFeatures, commitmentIndex, localParams, remoteParams, fundingTxIndex, remoteFundingPubKey, commitmentInput, remotePerCommitmentPoint, remoteSpec) + val sortedHtlcTxs = htlcTxs.sortBy(_.input.outPoint.index) + Right(localSpec, localCommitTx, remoteSpec, remoteCommitTx, sortedHtlcTxs) } } 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 7ee4a0dad6..ff8a9bc503 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 @@ -2650,8 +2650,9 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with spliceInAmount = cmd.additionalLocalFunding, spliceOut = cmd.spliceOutputs, targetFeerate = targetFeerate) - if (parentCommitment.localCommit.spec.toLocal + fundingContribution < parentCommitment.localChannelReserve(d.commitments.params)) { - log.warning("cannot do splice: insufficient funds") + val commitTxFees = Transactions.commitTxTotalCost(d.commitments.params.remoteParams.dustLimit, parentCommitment.remoteCommit.spec, d.commitments.params.commitmentFormat) + if (parentCommitment.localCommit.spec.toLocal + fundingContribution < parentCommitment.localChannelReserve(d.commitments.params).max(commitTxFees)) { + log.warning(s"cannot do splice: insufficient funds (commitTxFees=$commitTxFees reserve=${parentCommitment.localChannelReserve(d.commitments.params)})") Left(InvalidSpliceRequest(d.channelId)) } else if (cmd.spliceOut_opt.map(_.scriptPubKey).exists(!MutualClose.isValidFinalScriptPubkey(_, allowAnySegwit = true))) { log.warning("cannot do splice: invalid splice-out script") 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 dc358fbbda..03073100d1 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 @@ -32,7 +32,7 @@ import fr.acinq.eclair.channel.fund.InteractiveTxBuilder.Purpose import fr.acinq.eclair.channel.fund.InteractiveTxSigningSession.UnsignedLocalCommit import fr.acinq.eclair.crypto.keymanager.ChannelKeyManager import fr.acinq.eclair.transactions.Transactions.{CommitTx, HtlcTx, InputInfo, TxOwner} -import fr.acinq.eclair.transactions.{CommitmentSpec, Scripts, Transactions} +import fr.acinq.eclair.transactions.{CommitmentSpec, DirectedHtlc, Scripts, Transactions} import fr.acinq.eclair.wire.protocol._ import fr.acinq.eclair.{Logs, MilliSatoshi, MilliSatoshiLong, NodeParams, UInt64} import scodec.bits.ByteVector @@ -165,6 +165,8 @@ object InteractiveTxBuilder { def remotePerCommitmentPoint: PublicKey def commitTxFeerate: FeeratePerKw def fundingTxIndex: Long + def localHtlcs: Set[DirectedHtlc] + def htlcBalance: MilliSatoshi = localHtlcs.toSeq.map(_.add.amountMsat).sum } case class FundingTx(commitTxFeerate: FeeratePerKw, remotePerCommitmentPoint: PublicKey) extends Purpose { override val previousLocalBalance: MilliSatoshi = 0 msat @@ -173,6 +175,7 @@ object InteractiveTxBuilder { override val localCommitIndex: Long = 0 override val remoteCommitIndex: Long = 0 override val fundingTxIndex: Long = 0 + override val localHtlcs: Set[DirectedHtlc] = Set.empty } case class SpliceTx(parentCommitment: Commitment) extends Purpose { override val previousLocalBalance: MilliSatoshi = parentCommitment.localCommit.spec.toLocal @@ -183,6 +186,7 @@ object InteractiveTxBuilder { override val remotePerCommitmentPoint: PublicKey = parentCommitment.remoteCommit.remotePerCommitmentPoint override val commitTxFeerate: FeeratePerKw = parentCommitment.localCommit.spec.commitTxFeerate override val fundingTxIndex: Long = parentCommitment.fundingTxIndex + 1 + override val localHtlcs: Set[DirectedHtlc] = parentCommitment.localCommit.spec.htlcs } /** * @param previousTransactions interactive transactions are replaceable and can be RBF-ed, but we need to make sure that @@ -197,6 +201,7 @@ object InteractiveTxBuilder { override val remotePerCommitmentPoint: PublicKey = replacedCommitment.remoteCommit.remotePerCommitmentPoint override val commitTxFeerate: FeeratePerKw = replacedCommitment.localCommit.spec.commitTxFeerate override val fundingTxIndex: Long = replacedCommitment.fundingTxIndex + override val localHtlcs: Set[DirectedHtlc] = replacedCommitment.localCommit.spec.htlcs } // @formatter:on @@ -224,7 +229,7 @@ object InteractiveTxBuilder { case class Remote(serialId: UInt64, outPoint: OutPoint, txOut: TxOut, sequence: Long) extends Input with Incoming /** The shared input can be added by us or by our peer, depending on who initiated the protocol. */ - case class Shared(serialId: UInt64, outPoint: OutPoint, sequence: Long, localAmount: MilliSatoshi, remoteAmount: MilliSatoshi) extends Input with Incoming with Outgoing + case class Shared(serialId: UInt64, outPoint: OutPoint, sequence: Long, localAmount: MilliSatoshi, remoteAmount: MilliSatoshi, htlcAmount: MilliSatoshi) extends Input with Incoming with Outgoing } sealed trait Output { @@ -247,9 +252,9 @@ object InteractiveTxBuilder { case class Remote(serialId: UInt64, amount: Satoshi, pubkeyScript: ByteVector) extends Output with Incoming /** The shared output can be added by us or by our peer, depending on who initiated the protocol. */ - case class Shared(serialId: UInt64, pubkeyScript: ByteVector, localAmount: MilliSatoshi, remoteAmount: MilliSatoshi) extends Output with Incoming with Outgoing { + case class Shared(serialId: UInt64, pubkeyScript: ByteVector, localAmount: MilliSatoshi, remoteAmount: MilliSatoshi, htlcAmount: MilliSatoshi) extends Output with Incoming with Outgoing { // Note that the truncation is a no-op: the sum of balances in a channel must be a satoshi amount. - override val amount: Satoshi = (localAmount + remoteAmount).truncateToSatoshi + override val amount: Satoshi = (localAmount + remoteAmount + htlcAmount).truncateToSatoshi } } @@ -343,13 +348,14 @@ object InteractiveTxBuilder { Behaviors.withMdc(Logs.mdc(remoteNodeId_opt = Some(channelParams.remoteParams.nodeId), channelId_opt = Some(fundingParams.channelId))) { Behaviors.receiveMessagePartial { case Start(replyTo) => - val nextLocalFundingAmount = purpose.previousLocalBalance + fundingParams.localContribution - val nextRemoteFundingAmount = purpose.previousRemoteBalance + fundingParams.remoteContribution + // Note that pending HTLCs are ignored: splices only affect the main outputs. + val nextLocalBalance = purpose.previousLocalBalance + fundingParams.localContribution + val nextRemoteBalance = purpose.previousRemoteBalance + fundingParams.remoteContribution if (fundingParams.fundingAmount < fundingParams.dustLimit) { replyTo ! LocalFailure(FundingAmountTooLow(channelParams.channelId, fundingParams.fundingAmount, fundingParams.dustLimit)) Behaviors.stopped - } else if (nextLocalFundingAmount < 0.msat || nextRemoteFundingAmount < 0.msat) { - replyTo ! LocalFailure(InvalidFundingBalances(channelParams.channelId, fundingParams.fundingAmount, nextLocalFundingAmount, nextRemoteFundingAmount)) + } else if (nextLocalBalance < 0.msat || nextRemoteBalance < 0.msat) { + replyTo ! LocalFailure(InvalidFundingBalances(channelParams.channelId, fundingParams.fundingAmount, nextLocalBalance, nextRemoteBalance)) Behaviors.stopped } else { val actor = new InteractiveTxBuilder(replyTo, nodeParams, channelParams, fundingParams, purpose, localPushAmount, remotePushAmount, wallet, stash, context) @@ -478,7 +484,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon case None => (addInput.sharedInput_opt, fundingParams.sharedInput_opt) match { case (Some(outPoint), Some(sharedInput)) if outPoint == sharedInput.info.outPoint => - Input.Shared(addInput.serialId, outPoint, addInput.sequence, purpose.previousLocalBalance, purpose.previousRemoteBalance) + Input.Shared(addInput.serialId, outPoint, addInput.sequence, purpose.previousLocalBalance, purpose.previousRemoteBalance, purpose.htlcBalance) case _ => return Left(PreviousTxMissing(fundingParams.channelId, addInput.serialId)) } @@ -504,7 +510,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon } else if (!MutualClose.isValidFinalScriptPubkey(addOutput.pubkeyScript, allowAnySegwit = true)) { Left(InvalidSpliceOutputScript(fundingParams.channelId, addOutput.serialId, addOutput.pubkeyScript)) } else if (addOutput.pubkeyScript == fundingPubkeyScript) { - Right(Output.Shared(addOutput.serialId, addOutput.pubkeyScript, purpose.previousLocalBalance + fundingParams.localContribution, purpose.previousRemoteBalance + fundingParams.remoteContribution)) + Right(Output.Shared(addOutput.serialId, addOutput.pubkeyScript, purpose.previousLocalBalance + fundingParams.localContribution, purpose.previousRemoteBalance + fundingParams.remoteContribution, purpose.htlcBalance)) } else { Right(Output.Remote(addOutput.serialId, addOutput.amount, addOutput.pubkeyScript)) } @@ -722,10 +728,11 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon private def signCommitTx(completeTx: SharedTransaction): Behavior[Command] = { val fundingTx = completeTx.buildUnsignedTx() val fundingOutputIndex = fundingTx.txOut.indexWhere(_.publicKeyScript == fundingPubkeyScript) - Funding.makeCommitTxsWithoutHtlcs(keyManager, channelParams, + Funding.makeCommitTxs(keyManager, channelParams, fundingAmount = fundingParams.fundingAmount, toLocal = completeTx.sharedOutput.localAmount - localPushAmount + remotePushAmount, toRemote = completeTx.sharedOutput.remoteAmount - remotePushAmount + localPushAmount, + localHtlcs = purpose.localHtlcs, purpose.commitTxFeerate, fundingTxIndex = purpose.fundingTxIndex, fundingTx.hash, fundingOutputIndex, @@ -734,11 +741,13 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon case Left(cause) => replyTo ! RemoteFailure(cause) unlockAndStop(completeTx) - case Right((localSpec, localCommitTx, remoteSpec, remoteCommitTx)) => + case Right((localSpec, localCommitTx, remoteSpec, remoteCommitTx, sortedHtlcTxs)) => require(fundingTx.txOut(fundingOutputIndex).publicKeyScript == localCommitTx.input.txOut.publicKeyScript, "pubkey script mismatch!") val fundingPubKey = keyManager.fundingPublicKey(channelParams.localParams.fundingKeyPath, purpose.fundingTxIndex) val localSigOfRemoteTx = keyManager.sign(remoteCommitTx, fundingPubKey, TxOwner.Remote, channelParams.channelFeatures.commitmentFormat) - val localCommitSig = CommitSig(fundingParams.channelId, localSigOfRemoteTx, Nil) + val localPerCommitmentPoint = keyManager.htlcPoint(keyManager.keyPath(channelParams.localParams, channelParams.channelConfig)) + val htlcSignatures = sortedHtlcTxs.map(keyManager.sign(_, localPerCommitmentPoint, purpose.remotePerCommitmentPoint, TxOwner.Remote, channelParams.commitmentFormat)).toList + val localCommitSig = CommitSig(fundingParams.channelId, localSigOfRemoteTx, htlcSignatures) val localCommit = UnsignedLocalCommit(purpose.localCommitIndex, localSpec, localCommitTx, htlcTxs = Nil) val remoteCommit = RemoteCommit(purpose.remoteCommitIndex, remoteSpec, remoteCommitTx.tx.txid, purpose.remotePerCommitmentPoint) signFundingTx(completeTx, localCommitSig, localCommit, remoteCommit) @@ -924,24 +933,21 @@ object InteractiveTxSigningSession { localCommit: Either[UnsignedLocalCommit, LocalCommit], remoteCommit: RemoteCommit) extends InteractiveTxSigningSession { val commitInput: InputInfo = localCommit.fold(_.commitTx.input, _.commitTxAndRemoteSig.commitTx.input) + val localCommitIndex: Long = localCommit.fold(_.index, _.index) def receiveCommitSig(nodeParams: NodeParams, channelParams: ChannelParams, remoteCommitSig: CommitSig)(implicit log: LoggingAdapter): Either[ChannelException, InteractiveTxSigningSession] = { localCommit match { case Left(unsignedLocalCommit) => - val fundingPubKey = nodeParams.channelKeyManager.fundingPublicKey(channelParams.localParams.fundingKeyPath, fundingTxIndex) - val localSigOfLocalTx = nodeParams.channelKeyManager.sign(unsignedLocalCommit.commitTx, fundingPubKey, TxOwner.Local, channelParams.channelFeatures.commitmentFormat) - val signedLocalCommitTx = Transactions.addSigs(unsignedLocalCommit.commitTx, fundingPubKey.publicKey, fundingParams.remoteFundingPubKey, localSigOfLocalTx, remoteCommitSig.signature) - Transactions.checkSpendable(signedLocalCommitTx) match { - case Failure(_) => Left(InvalidCommitmentSignature(fundingParams.channelId, fundingTx.txId, fundingTxIndex, unsignedLocalCommit.commitTx.tx)) - case Success(_) => - val signedLocalCommit = LocalCommit(unsignedLocalCommit.index, unsignedLocalCommit.spec, CommitTxAndRemoteSig(unsignedLocalCommit.commitTx, remoteCommitSig.signature), htlcTxsAndRemoteSigs = Nil) - if (shouldSignFirst(channelParams, fundingTx.tx)) { - val fundingStatus = LocalFundingStatus.DualFundedUnconfirmedFundingTx(fundingTx, nodeParams.currentBlockHeight, fundingParams) - val commitment = Commitment(fundingTxIndex, fundingParams.remoteFundingPubKey, fundingStatus, RemoteFundingStatus.NotLocked, signedLocalCommit, remoteCommit, None) - Right(SendingSigs(fundingStatus, commitment, fundingTx.localSigs)) - } else { - Right(this.copy(localCommit = Right(signedLocalCommit))) - } + val channelKeyPath = nodeParams.channelKeyManager.keyPath(channelParams.localParams, channelParams.channelConfig) + val localPerCommitmentPoint = nodeParams.channelKeyManager.commitmentPoint(channelKeyPath, localCommitIndex) + LocalCommit.fromCommitSig(nodeParams.channelKeyManager, channelParams, fundingTx.txId, fundingTxIndex, fundingParams.remoteFundingPubKey, commitInput, remoteCommitSig, localCommitIndex, unsignedLocalCommit.spec, localPerCommitmentPoint).map { signedLocalCommit => + if (shouldSignFirst(channelParams, fundingTx.tx)) { + val fundingStatus = LocalFundingStatus.DualFundedUnconfirmedFundingTx(fundingTx, nodeParams.currentBlockHeight, fundingParams) + val commitment = Commitment(fundingTxIndex, fundingParams.remoteFundingPubKey, fundingStatus, RemoteFundingStatus.NotLocked, signedLocalCommit, remoteCommit, None) + SendingSigs(fundingStatus, commitment, fundingTx.localSigs) + } else { + this.copy(localCommit = Right(signedLocalCommit)) + } } case Right(_) => log.info("ignoring duplicate commit_sig") diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxFunder.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxFunder.scala index 3c02183db8..55a83fa16b 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxFunder.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxFunder.scala @@ -156,8 +156,8 @@ private class InteractiveTxFunder(replyTo: ActorRef[InteractiveTxFunder.Response // force us to add wallet inputs. The caller may manually decrease the output amounts if it wants to actually // contribute to the RBF attempt. if (fundingParams.isInitiator) { - val sharedInput = fundingParams.sharedInput_opt.toSeq.map(sharedInput => Input.Shared(UInt64(0), sharedInput.info.outPoint, 0xfffffffdL, purpose.previousLocalBalance, purpose.previousRemoteBalance)) - val sharedOutput = Output.Shared(UInt64(0), fundingPubkeyScript, purpose.previousLocalBalance + fundingParams.localContribution, purpose.previousRemoteBalance + fundingParams.remoteContribution) + val sharedInput = fundingParams.sharedInput_opt.toSeq.map(sharedInput => Input.Shared(UInt64(0), sharedInput.info.outPoint, 0xfffffffdL, purpose.previousLocalBalance, purpose.previousRemoteBalance, purpose.htlcBalance)) + val sharedOutput = Output.Shared(UInt64(0), fundingPubkeyScript, purpose.previousLocalBalance + fundingParams.localContribution, purpose.previousRemoteBalance + fundingParams.remoteContribution, purpose.htlcBalance) val nonChangeOutputs = fundingParams.localOutputs.map(txOut => Output.Local.NonChange(UInt64(0), txOut.amount, txOut.publicKeyScript)) val fundingContributions = sortFundingContributions(fundingParams, sharedInput ++ previousWalletInputs, sharedOutput +: nonChangeOutputs) replyTo ! fundingContributions @@ -232,7 +232,7 @@ private class InteractiveTxFunder(replyTo: ActorRef[InteractiveTxFunder.Response val fundingContributions = if (fundingParams.isInitiator) { // The initiator is responsible for adding the shared output and the shared input. val inputs = inputDetails.usableInputs - val fundingOutput = Output.Shared(UInt64(0), fundingPubkeyScript, purpose.previousLocalBalance + fundingParams.localContribution, purpose.previousRemoteBalance + fundingParams.remoteContribution) + val fundingOutput = Output.Shared(UInt64(0), fundingPubkeyScript, purpose.previousLocalBalance + fundingParams.localContribution, purpose.previousRemoteBalance + fundingParams.remoteContribution, purpose.htlcBalance) val outputs = Seq(fundingOutput) ++ nonChangeOutputs ++ changeOutput_opt.toSeq sortFundingContributions(fundingParams, inputs, outputs) } else { @@ -291,7 +291,7 @@ private class InteractiveTxFunder(replyTo: ActorRef[InteractiveTxFunder.Response case None => fundingParams.sharedInput_opt match { case Some(sharedInput) if sharedInput.info.outPoint == txIn.outPoint => // We don't need to validate the shared input, it comes from a valid lightning channel. - Future.successful(Right(Input.Shared(UInt64(0), sharedInput.info.outPoint, txIn.sequence, purpose.previousLocalBalance, purpose.previousRemoteBalance))) + Future.successful(Right(Input.Shared(UInt64(0), sharedInput.info.outPoint, txIn.sequence, purpose.previousLocalBalance, purpose.previousRemoteBalance, purpose.htlcBalance))) case _ => for { previousTx <- wallet.getTransaction(txIn.outPoint.txid) 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 68fb2cfb67..fd301d0208 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 @@ -3,13 +3,13 @@ package fr.acinq.eclair.wire.internal.channel.version4 import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.bitcoin.scalacompat.DeterministicWallet.KeyPath import fr.acinq.bitcoin.scalacompat.{OutPoint, ScriptWitness, Transaction, TxOut} -import fr.acinq.eclair.blockchain.fee import fr.acinq.eclair.blockchain.fee.{ConfirmationPriority, ConfirmationTarget} import fr.acinq.eclair.channel.LocalFundingStatus._ import fr.acinq.eclair.channel._ 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.MilliSatoshiLong import fr.acinq.eclair.transactions.Transactions._ import fr.acinq.eclair.transactions.{CommitmentSpec, DirectedHtlc, IncomingHtlc, OutgoingHtlc} import fr.acinq.eclair.wire.protocol.CommonCodecs._ @@ -248,20 +248,44 @@ private[channel] object ChannelCodecs4 { ("targetFeerate" | feeratePerKw) :: ("requireConfirmedInputs" | requireConfirmedInputsCodec)).as[InteractiveTxBuilder.InteractiveTxParams] + // This codec was used by a first prototype version of splicing that only worked without HTLCs. + private val sharedInteractiveTxInputWithoutHtlcsCodec: Codec[InteractiveTxBuilder.Input.Shared] = ( + ("serialId" | uint64) :: + ("outPoint" | outPointCodec) :: + ("sequence" | uint32) :: + ("localAmount" | millisatoshi) :: + ("remoteAmount" | millisatoshi) :: + ("htlcAmount" | provide(0 msat))).as[InteractiveTxBuilder.Input.Shared] + + private val sharedInteractiveTxInputWithHtlcsCodec: Codec[InteractiveTxBuilder.Input.Shared] = ( + ("serialId" | uint64) :: + ("outPoint" | outPointCodec) :: + ("sequence" | uint32) :: + ("localAmount" | millisatoshi) :: + ("remoteAmount" | millisatoshi) :: + ("htlcAmount" | millisatoshi)).as[InteractiveTxBuilder.Input.Shared] + private val sharedInteractiveTxInputCodec: Codec[InteractiveTxBuilder.Input.Shared] = discriminated[InteractiveTxBuilder.Input.Shared].by(byte) - .typecase(0x01, ( - ("serialId" | uint64) :: - ("outPoint" | outPointCodec) :: - ("sequence" | uint32) :: - ("localAmount" | millisatoshi) :: - ("remoteAmount" | millisatoshi)).as[InteractiveTxBuilder.Input.Shared]) + .typecase(0x02, sharedInteractiveTxInputWithHtlcsCodec) + .typecase(0x01, sharedInteractiveTxInputWithoutHtlcsCodec) + + private val sharedInteractiveTxOutputWithoutHtlcsCodec: Codec[InteractiveTxBuilder.Output.Shared] = ( + ("serialId" | uint64) :: + ("scriptPubKey" | lengthDelimited(bytes)) :: + ("localAmount" | millisatoshi) :: + ("remoteAmount" | millisatoshi) :: + ("htlcAmount" | provide(0 msat))).as[InteractiveTxBuilder.Output.Shared] + + private val sharedInteractiveTxOutputWithHtlcsCodec: Codec[InteractiveTxBuilder.Output.Shared] = ( + ("serialId" | uint64) :: + ("scriptPubKey" | lengthDelimited(bytes)) :: + ("localAmount" | millisatoshi) :: + ("remoteAmount" | millisatoshi) :: + ("htlcAmount" | millisatoshi)).as[InteractiveTxBuilder.Output.Shared] private val sharedInteractiveTxOutputCodec: Codec[InteractiveTxBuilder.Output.Shared] = discriminated[InteractiveTxBuilder.Output.Shared].by(byte) - .typecase(0x01, ( - ("serialId" | uint64) :: - ("scriptPubKey" | lengthDelimited(bytes)) :: - ("localAmount" | millisatoshi) :: - ("remoteAmount" | millisatoshi)).as[InteractiveTxBuilder.Output.Shared]) + .typecase(0x02, sharedInteractiveTxOutputWithHtlcsCodec) + .typecase(0x01, sharedInteractiveTxOutputWithoutHtlcsCodec) private val localOnlyInteractiveTxInputCodec: Codec[InteractiveTxBuilder.Input.Local] = ( ("serialId" | uint64) :: 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 36e1908595..90da1e2f50 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 @@ -2530,7 +2530,7 @@ class InteractiveTxBuilderSpec extends TestKitBaseClass with AnyFunSuiteLike wit test("reference test vector") { val channelId = ByteVector32.Zeroes val parentTx = Transaction.read("02000000000101f86fd1d0db3ac5a72df968622f31e6b5e6566a09e29206d7c7a55df90e181de800000000171600141fb9623ffd0d422eacc450fd1e967efc477b83ccffffffff0580b2e60e00000000220020fd89acf65485df89797d9ba7ba7a33624ac4452f00db08107f34257d33e5b94680b2e60e0000000017a9146a235d064786b49e7043e4a042d4cc429f7eb6948780b2e60e00000000160014fbb4db9d85fba5e301f4399e3038928e44e37d3280b2e60e0000000017a9147ecd1b519326bc13b0ec716e469b58ed02b112a087f0006bee0000000017a914f856a70093da3a5b5c4302ade033d4c2171705d387024730440220696f6cee2929f1feb3fd6adf024ca0f9aa2f4920ed6d35fb9ec5b78c8408475302201641afae11242160101c6f9932aeb4fcd1f13a9c6df5d1386def000ea259a35001210381d7d5b1bc0d7600565d827242576d9cb793bfe0754334af82289ee8b65d137600000000") - val sharedOutput = Output.Shared(UInt64(44), hex"0020297b92c238163e820b82486084634b4846b86a3c658d87b9384192e6bea98ec5", 200_000_000_000L msat, 200_000_000_000L msat) + val sharedOutput = Output.Shared(UInt64(44), hex"0020297b92c238163e820b82486084634b4846b86a3c658d87b9384192e6bea98ec5", 200_000_000_000L msat, 200_000_000_000L msat, 0 msat) val initiatorTx = { val initiatorInput = Input.Local(UInt64(20), parentTx, 0, 4294967293L) val initiatorOutput = Output.Local.Change(UInt64(30), 49_999_845 sat, hex"00141ca1cca8855bad6bc1ea5436edd8cff10b7e448b") 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 29cbb975eb..d8ed58c761 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 @@ -19,7 +19,9 @@ package fr.acinq.eclair.channel.states.e import akka.actor.ActorRef import akka.actor.typed.scaladsl.adapter.actorRefAdapter import akka.testkit.{TestFSMRef, TestProbe} -import fr.acinq.bitcoin.scalacompat.{SatoshiLong, Transaction} +import fr.acinq.bitcoin.ScriptFlags +import fr.acinq.bitcoin.scalacompat.NumericSatoshi.abs +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, SatoshiLong, Transaction, TxIn} import fr.acinq.eclair._ import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.fee.FeeratePerKw @@ -33,6 +35,7 @@ import fr.acinq.eclair.channel.states.ChannelStateTestsBase.{FakeTxPublisherFact import fr.acinq.eclair.channel.states.ChannelStateTestsTags.{AnchorOutputsZeroFeeHtlcTxs, NoMaxHtlcValueInFlight, ZeroConf} import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags} 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.ClaimLocalAnchorOutputTx import fr.acinq.eclair.wire.protocol._ @@ -65,12 +68,17 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik private val defaultSpliceOutScriptPubKey = hex"0020aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + private def useQuiescence(f: FixtureParam): Boolean = f.alice.stateData.asInstanceOf[ChannelDataWithCommitments].commitments.params.useQuiescence + private def initiateSpliceWithoutSigs(f: FixtureParam, spliceIn_opt: Option[SpliceIn] = None, spliceOut_opt: Option[SpliceOut] = None): TestProbe = { import f._ val sender = TestProbe() val cmd = CMD_SPLICE(sender.ref, spliceIn_opt, spliceOut_opt) alice ! cmd + if (useQuiescence(f)) { + exchangeStfu(f) + } alice2bob.expectMsgType[SpliceInit] alice2bob.forward(bob) bob2alice.expectMsgType[SpliceAck] @@ -144,6 +152,101 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik exchangeSpliceSigs(f, sender) } + private def exchangeStfu(f: FixtureParam): Unit = { + import f._ + alice2bob.expectMsgType[Stfu] + alice2bob.forward(bob) + bob2alice.expectMsgType[Stfu] + bob2alice.forward(alice) + } + + case class TestHtlcs(aliceToBob: Seq[(ByteVector32, UpdateAddHtlc)], bobToAlice: Seq[(ByteVector32, UpdateAddHtlc)]) + + private def setupHtlcs(f: FixtureParam): TestHtlcs = { + import f._ + + if (useQuiescence(f)) { + // add htlcs in both directions + val htlcsAliceToBob = Seq( + addHtlc(15_000_000 msat, alice, bob, alice2bob, bob2alice), + addHtlc(15_000_000 msat, alice, bob, alice2bob, bob2alice) + ) + crossSign(alice, bob, alice2bob, bob2alice) + val htlcsBobToAlice = Seq( + addHtlc(20_000_000 msat, bob, alice, bob2alice, alice2bob), + addHtlc(15_000_000 msat, bob, alice, bob2alice, alice2bob) + ) + crossSign(bob, alice, bob2alice, alice2bob) + + val initialState = alice.stateData.asInstanceOf[DATA_NORMAL] + assert(initialState.commitments.latest.capacity == 1_500_000.sat) + assert(initialState.commitments.latest.localCommit.spec.toLocal == 770_000_000.msat) + assert(initialState.commitments.latest.localCommit.spec.toRemote == 665_000_000.msat) + + TestHtlcs(htlcsAliceToBob, htlcsBobToAlice) + } else { + val initialState = alice.stateData.asInstanceOf[DATA_NORMAL] + assert(initialState.commitments.latest.capacity == 1_500_000.sat) + assert(initialState.commitments.latest.localCommit.spec.toLocal == 800_000_000.msat) + assert(initialState.commitments.latest.localCommit.spec.toRemote == 700_000_000.msat) + TestHtlcs(Seq.empty, Seq.empty) + } + } + + def spliceOutFee(f: FixtureParam, capacity: Satoshi): Satoshi = { + import f._ + + // When we only splice-out, the fees are paid by deducing them from the next funding amount. + val fundingTx = alice.stateData.asInstanceOf[ChannelDataWithCommitments].commitments.latest.localFundingStatus.signedTx_opt.get + val feerate = alice.nodeParams.onChainFeeConf.getFundingFeerate(alice.nodeParams.currentFeerates) + val expectedMiningFee = Transactions.weight2fee(feerate, fundingTx.weight()) + val actualMiningFee = capacity - alice.stateData.asInstanceOf[ChannelDataWithCommitments].commitments.latest.capacity + // Fee computation is approximate (signature size isn't constant). + assert(actualMiningFee >= 0.sat && abs(actualMiningFee - expectedMiningFee) < 100.sat) + actualMiningFee + } + + def checkPostSpliceState(f: FixtureParam, spliceOutFee: Satoshi): Unit = { + import f._ + + // if the swap includes a splice-in, swap-out fees will be paid from bitcoind so final capacity is predictable + val (outgoingHtlcs, incomingHtlcs) = if (useQuiescence(f)) (30_000_000.msat, 35_000_000.msat) else (0.msat, 0.msat) + val postSpliceState = alice.stateData.asInstanceOf[ChannelDataWithCommitments] + assert(postSpliceState.commitments.latest.capacity == 1_900_000.sat - spliceOutFee) + assert(postSpliceState.commitments.latest.localCommit.spec.toLocal == 1_200_000_000.msat - spliceOutFee - outgoingHtlcs) + assert(postSpliceState.commitments.latest.localCommit.spec.toRemote == 700_000_000.msat - incomingHtlcs) + assert(postSpliceState.commitments.latest.localCommit.spec.htlcs.collect(incoming).toSeq.map(_.amountMsat).sum == incomingHtlcs) + assert(postSpliceState.commitments.latest.localCommit.spec.htlcs.collect(outgoing).toSeq.map(_.amountMsat).sum == outgoingHtlcs) + } + + def resolveHtlcs(f: FixtureParam, htlcs: TestHtlcs, spliceOutFee: Satoshi): Unit = { + import f._ + + checkPostSpliceState(f, spliceOutFee) + + if (useQuiescence(f)) { + // resolve pre-splice HTLCs after splice + val Seq((preimage1a, htlc1a), (preimage2a, htlc2a)) = htlcs.aliceToBob + val Seq((preimage1b, htlc1b), (preimage2b, htlc2b)) = htlcs.bobToAlice + fulfillHtlc(htlc1a.id, preimage1a, bob, alice, bob2alice, alice2bob) + fulfillHtlc(htlc2a.id, preimage2a, bob, alice, bob2alice, alice2bob) + crossSign(bob, alice, bob2alice, alice2bob) + fulfillHtlc(htlc1b.id, preimage1b, alice, bob, alice2bob, bob2alice) + fulfillHtlc(htlc2b.id, preimage2b, alice, bob, alice2bob, bob2alice) + crossSign(alice, bob, alice2bob, bob2alice) + assert(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.active.head.localCommit.spec.htlcs.collect(outgoing).isEmpty) + assert(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.active.head.remoteCommit.spec.htlcs.collect(outgoing).isEmpty) + assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.active.head.localCommit.spec.htlcs.collect(outgoing).isEmpty) + assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.active.head.remoteCommit.spec.htlcs.collect(outgoing).isEmpty) + } + + val settledHtlcs = if (useQuiescence(f)) 5_000_000.msat else 0.msat + val finalState = alice.stateData.asInstanceOf[DATA_NORMAL] + assert(finalState.commitments.latest.capacity == 1_900_000.sat - spliceOutFee) + assert(finalState.commitments.latest.localCommit.spec.toLocal == 1_200_000_000.msat - spliceOutFee + settledHtlcs) + assert(finalState.commitments.latest.localCommit.spec.toRemote == 700_000_000.msat - settledHtlcs) + } + test("recv CMD_SPLICE (splice-in)") { f => import f._ @@ -169,31 +272,35 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik initiateSplice(f, spliceOut_opt = Some(SpliceOut(100_000 sat, defaultSpliceOutScriptPubKey))) - val fundingTx1 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localFundingStatus.signedTx_opt.get - val feerate = alice.nodeParams.onChainFeeConf.getFundingFeerate(alice.nodeParams.currentFeerates) - val expectedMiningFee = Transactions.weight2fee(feerate, fundingTx1.weight()) - val actualMiningFee = 1_400_000.sat - alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.capacity - // fee computation is approximate - assert(actualMiningFee - expectedMiningFee < 100.sat || expectedMiningFee - actualMiningFee < 100.sat) // initiator pays the fee - assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localCommit.spec.toLocal == 700_000_000.msat - actualMiningFee) + val fee = spliceOutFee(f, capacity = 1_400_000.sat) + assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localCommit.spec.toLocal == 700_000_000.msat - fee) assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localCommit.spec.toRemote == 700_000_000.msat) } test("recv CMD_SPLICE (splice-out, would go below reserve)") { f => import f._ - val initialState = alice.stateData.asInstanceOf[DATA_NORMAL] - assert(initialState.commitments.latest.capacity == 1_500_000.sat) - assert(initialState.commitments.latest.localCommit.spec.toLocal == 800_000_000.msat) - assert(initialState.commitments.latest.localCommit.spec.toRemote == 700_000_000.msat) + setupHtlcs(f) val sender = TestProbe() - val cmd = CMD_SPLICE(sender.ref, spliceIn_opt = None, Some(SpliceOut(790_000 sat, defaultSpliceOutScriptPubKey))) + val cmd = CMD_SPLICE(sender.ref, spliceIn_opt = None, Some(SpliceOut(780_000.sat, defaultSpliceOutScriptPubKey))) alice ! cmd sender.expectMsgType[RES_FAILURE[_, _]] } + test("recv CMD_SPLICE (splice-out, would go below reserve, quiescent)", Tag(ChannelStateTestsTags.Quiescence), Tag(NoMaxHtlcValueInFlight)) { f => + import f._ + + setupHtlcs(f) + + val sender = TestProbe() + val cmd = CMD_SPLICE(sender.ref, spliceIn_opt = None, Some(SpliceOut(760_000 sat, defaultSpliceOutScriptPubKey))) + alice ! cmd + exchangeStfu(f) + sender.expectMsgType[RES_FAILURE[_, _]] + } + test("recv CMD_SPLICE (splice-in, feerate too low)") { f => import f._ @@ -232,20 +339,20 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik awaitCond(bob.stateData.asInstanceOf[DATA_NORMAL].spliceStatus == SpliceStatus.NoSplice) } - test("recv CMD_SPLICE (splice-in + splice-out)") { f => - import f._ - - val initialState = alice.stateData.asInstanceOf[DATA_NORMAL] - assert(initialState.commitments.latest.capacity == 1_500_000.sat) - assert(initialState.commitments.latest.localCommit.spec.toLocal == 800_000_000.msat) - assert(initialState.commitments.latest.localCommit.spec.toRemote == 700_000_000.msat) + def testSpliceInAndOutCmd(f: FixtureParam): Unit = { + val htlcs = setupHtlcs(f) initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat)), spliceOut_opt = Some(SpliceOut(100_000 sat, defaultSpliceOutScriptPubKey))) - // NB: since there is a splice-in, swap-out fees will be paid from bitcoind so final capacity is predictable - assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.capacity == 1_900_000.sat) - assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localCommit.spec.toLocal == 1_200_000_000.msat) - assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localCommit.spec.toRemote == 700_000_000.msat) + resolveHtlcs(f, htlcs, spliceOutFee = 0.sat) + } + + test("recv CMD_SPLICE (splice-in + splice-out)") { f => + testSpliceInAndOutCmd(f) + } + + test("recv CMD_SPLICE (splice-in + splice-out, quiescence)", Tag(ChannelStateTestsTags.Quiescence)) { f => + testSpliceInAndOutCmd(f) } test("recv TxAbort (before TxComplete)") { f => @@ -828,10 +935,12 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik assert(alice.stateData.asInstanceOf[DATA_NORMAL].spliceStatus == SpliceStatus.NoSplice) } - test("disconnect (commit_sig not received)") { f => + def testDisconnectCommitSigNotReceived(f: FixtureParam): Unit = { import f._ - val sender = initiateSpliceWithoutSigs(f, spliceOut_opt = Some(SpliceOut(20_000 sat, defaultSpliceOutScriptPubKey))) + val htlcs = setupHtlcs(f) + + val sender = 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]) @@ -853,12 +962,24 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik bob2alice.forward(alice) awaitCond(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.active.size == 1) awaitCond(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.active.size == 1) + + resolveHtlcs(f, htlcs, 0.sat) } - test("disconnect (commit_sig received by alice)") { f => + test("disconnect (commit_sig not received)") { f => + testDisconnectCommitSigNotReceived(f) + } + + test("disconnect (commit_sig not received, quiescence)", Tag(ChannelStateTestsTags.Quiescence)) { f => + testDisconnectCommitSigNotReceived(f) + } + + def testDisconnectCommitSigReceivedByAlice(f: FixtureParam): Unit = { import f._ - val sender = initiateSpliceWithoutSigs(f, spliceOut_opt = Some(SpliceOut(20_000 sat, defaultSpliceOutScriptPubKey))) + val htlcs = setupHtlcs(f) + + val sender = 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) @@ -881,12 +1002,24 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik bob2alice.forward(alice) awaitCond(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.active.size == 1) awaitCond(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.active.size == 1) + + resolveHtlcs(f, htlcs, spliceOutFee = 0.sat) } - test("disconnect (tx_signatures sent by bob)") { f => + test("disconnect (commit_sig received by alice)") { f => + testDisconnectCommitSigReceivedByAlice(f) + } + + test("disconnect (commit_sig received by alice, quiescence)", Tag(ChannelStateTestsTags.Quiescence)) { f => + testDisconnectCommitSigReceivedByAlice(f) + } + + def testDisconnectTxSignaturesSentByBob(f: FixtureParam): Unit = { import f._ - val sender = initiateSpliceWithoutSigs(f, spliceOut_opt = Some(SpliceOut(20_000 sat, defaultSpliceOutScriptPubKey))) + val htlcs = setupHtlcs(f) + + val sender = initiateSpliceWithoutSigs(f, spliceIn_opt = Some(SpliceIn(500_000 sat)), spliceOut_opt = Some(SpliceOut(100_000 sat, defaultSpliceOutScriptPubKey))) alice2bob.expectMsgType[CommitSig] alice2bob.forward(bob) bob2alice.expectMsgType[CommitSig] @@ -911,12 +1044,24 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik bob2alice.forward(alice) awaitCond(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.active.size == 1) awaitCond(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.active.size == 1) + + resolveHtlcs(f, htlcs, spliceOutFee = 0.sat) } - test("disconnect (tx_signatures received by alice)") { f => + test("disconnect (tx_signatures sent by bob)") { f => + testDisconnectTxSignaturesSentByBob(f) + } + + test("disconnect (tx_signatures sent by bob, quiescence)", Tag(ChannelStateTestsTags.Quiescence)) { f => + testDisconnectTxSignaturesSentByBob(f) + } + + def testDisconnectTxSignaturesReceivedByAlice(f: FixtureParam): Unit = { import f._ - initiateSpliceWithoutSigs(f, spliceOut_opt = Some(SpliceOut(20_000 sat, defaultSpliceOutScriptPubKey))) + val htlcs = setupHtlcs(f) + + initiateSpliceWithoutSigs(f, spliceIn_opt = Some(SpliceIn(500_000 sat)), spliceOut_opt = Some(SpliceOut(100_000 sat, defaultSpliceOutScriptPubKey))) alice2bob.expectMsgType[CommitSig] alice2bob.forward(bob) bob2alice.expectMsgType[CommitSig] @@ -948,32 +1093,44 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik bob2alice.forward(alice) awaitCond(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.active.size == 1) awaitCond(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.active.size == 1) + + resolveHtlcs(f, htlcs, spliceOutFee = 0.sat) } - test("disconnect (tx_signatures received by alice, zero-conf", Tag(ChannelStateTestsTags.ZeroConf), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + test("disconnect (tx_signatures received by alice)") { f => + testDisconnectTxSignaturesReceivedByAlice(f) + } + + test("disconnect (tx_signatures received by alice, quiescence)", Tag(ChannelStateTestsTags.Quiescence)) { f => + testDisconnectTxSignaturesReceivedByAlice(f) + } + + def testDisconnectTxSignaturesReceivedByAliceZeroConf(f: FixtureParam): Unit = { import f._ - initiateSpliceWithoutSigs(f, spliceOut_opt = Some(SpliceOut(20_000 sat, defaultSpliceOutScriptPubKey))) + val htlcs = setupHtlcs(f) + + initiateSpliceWithoutSigs(f, spliceIn_opt = Some(SpliceIn(500_000 sat)), spliceOut_opt = Some(SpliceOut(100_000 sat, defaultSpliceOutScriptPubKey))) alice2bob.expectMsgType[CommitSig] alice2bob.forward(bob) bob2alice.expectMsgType[CommitSig] bob2alice.forward(alice) bob2alice.expectMsgType[TxSignatures] bob2alice.forward(alice) - awaitCond(bob.stateData.asInstanceOf[DATA_NORMAL].spliceStatus == SpliceStatus.NoSplice) - alice2bob.expectMsgType[TxSignatures] // Bob doesn't receive Alice's tx_signatures + val spliceTxId = alice2bob.expectMsgType[TxSignatures].txId // Bob doesn't receive Alice's tx_signatures alice2blockchain.expectMsgType[WatchPublished] awaitCond(alice.stateData.asInstanceOf[DATA_NORMAL].spliceStatus == SpliceStatus.NoSplice) + awaitCond(bob.stateData.asInstanceOf[DATA_NORMAL].spliceStatus == SpliceStatus.NoSplice) val spliceTx = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localFundingStatus.signedTx_opt.get alice ! WatchPublishedTriggered(spliceTx) - assert(alice2bob.expectMsgType[SpliceLocked].fundingTxid == spliceTx.txid) // Bob doesn't receive Alice's splice_locked + assert(alice2bob.expectMsgType[SpliceLocked].fundingTxid == spliceTxId) // Bob doesn't receive Alice's splice_locked disconnect(f) val (channelReestablishAlice, channelReestablishBob) = reconnect(f, interceptFundingDeeplyBuried = false) assert(channelReestablishAlice.nextFundingTxId_opt.isEmpty) - assert(channelReestablishBob.nextFundingTxId_opt.contains(spliceTx.txid)) - alice2blockchain.expectWatchFundingConfirmed(spliceTx.txid) - bob2blockchain.expectWatchPublished(spliceTx.txid) + assert(channelReestablishBob.nextFundingTxId_opt.contains(spliceTxId)) + alice2blockchain.expectWatchFundingConfirmed(spliceTxId) + bob2blockchain.expectWatchPublished(spliceTxId) bob2blockchain.expectMsgType[WatchFundingDeeplyBuried] alice2bob.expectMsgType[TxSignatures] @@ -986,8 +1143,19 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik bob2alice.forward(alice) awaitCond(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.active.size == 1) awaitCond(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.active.size == 1) + + resolveHtlcs(f, htlcs, spliceOutFee = 0.sat) } + test("disconnect (tx_signatures received by alice, zero-conf)", Tag(ChannelStateTestsTags.ZeroConf), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + testDisconnectTxSignaturesReceivedByAliceZeroConf(f) + } + + test("disconnect (tx_signatures received by alice, zero-conf, quiescence)", Tag(ChannelStateTestsTags.ZeroConf), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs), Tag(ChannelStateTestsTags.Quiescence)) { f => + testDisconnectTxSignaturesReceivedByAliceZeroConf(f) + } + + test("disconnect (tx_signatures sent by alice, splice confirms while bob is offline)") { f => import f._ @@ -1141,15 +1309,17 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik } } - test("force-close with multiple splices (simple)") { f => + def testForceCloseWithMultipleSplicesSimple(f: FixtureParam): Unit = { import f._ - initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat, pushAmount = 0 msat))) + val htlcs = setupHtlcs(f) + + initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat))) val fundingTx1 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localFundingStatus.signedTx_opt.get - val watchConfirmed1 = alice2blockchain.expectMsgType[WatchFundingConfirmed] - initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat, pushAmount = 0 msat))) + val watchConfirmed1 = alice2blockchain.expectWatchFundingConfirmed(fundingTx1.txid) + initiateSplice(f, spliceOut_opt = Some(SpliceOut(100_000 sat, defaultSpliceOutScriptPubKey))) val fundingTx2 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localFundingStatus.signedTx_opt.get - val watchConfirmed2 = alice2blockchain.expectMsgType[WatchFundingConfirmed] + val watchConfirmed2 = alice2blockchain.expectWatchFundingConfirmed(fundingTx2.txid) alice2bob.expectNoMessage(100 millis) bob2alice.expectNoMessage(100 millis) alice2blockchain.expectNoMessage(100 millis) @@ -1158,42 +1328,76 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik alice ! CMD_FORCECLOSE(ActorRef.noSender) alice2bob.expectMsgType[Error] val commitTx2 = assertPublished(alice2blockchain, "commit-tx") + Transaction.correctlySpends(commitTx2, Seq(fundingTx2), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) val claimMainDelayed2 = assertPublished(alice2blockchain, "local-main-delayed") - val watchConfirmedCommit2 = alice2blockchain.expectMsgType[WatchTxConfirmed] - val watchConfirmedClaimMainDelayed2 = alice2blockchain.expectMsgType[WatchTxConfirmed] - alice ! WatchFundingSpentTriggered(commitTx2) - alice2blockchain.expectNoMessage(100 millis) + // alice publishes her htlc timeout transactions + val htlcsTxsOut = htlcs.aliceToBob.map(_ => assertPublished(alice2blockchain, "htlc-timeout")) + htlcsTxsOut.foreach(tx => Transaction.correctlySpends(tx, Seq(commitTx2), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)) + + val watchConfirmedCommit2 = alice2blockchain.expectWatchTxConfirmed(commitTx2.txid) + val watchConfirmedClaimMainDelayed2 = alice2blockchain.expectWatchTxConfirmed(claimMainDelayed2.txid) + // watch for all htlc outputs from local commit-tx to be spent + val watchHtlcsOut = htlcs.aliceToBob.map(_ => alice2blockchain.expectMsgType[WatchOutputSpent]) + htlcs.bobToAlice.map(_ => alice2blockchain.expectMsgType[WatchOutputSpent]) // splice 1 confirms watchConfirmed1.replyTo ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx1) alice2bob.forward(bob) alice2blockchain.expectMsgType[WatchFundingSpent] - alice2blockchain.expectNoMessage(100 millis) // splice 2 confirms watchConfirmed2.replyTo ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx2) alice2bob.forward(bob) alice2blockchain.expectMsgType[WatchFundingSpent] - alice2blockchain.expectNoMessage(100 millis) // commit tx confirms watchConfirmedCommit2.replyTo ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, commitTx2) - // claim-main-delayed tx confirms watchConfirmedClaimMainDelayed2.replyTo ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, claimMainDelayed2) + // alice's htlc-timeout txs confirm + watchHtlcsOut.zip(htlcsTxsOut).foreach { case (watch, tx) => watch.replyTo ! WatchOutputSpentTriggered(tx) } + htlcsTxsOut.foreach { tx => + alice2blockchain.expectWatchTxConfirmed(tx.txid) + alice ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, tx) + } + + // alice publishes, watches and confirms their 2nd-stage htlc-delayed txs + htlcs.aliceToBob.foreach { _ => + val tx = assertPublished(alice2blockchain, "htlc-delayed") + alice2blockchain.expectWatchTxConfirmed(tx.txid) + alice ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, tx) + } + + // confirm bob's htlc-timeout txs + val remoteOutpoints = alice.stateData.asInstanceOf[DATA_CLOSING].localCommitPublished.map(rcp => rcp.htlcTxs.filter(_._2.isEmpty).keys).toSeq.flatten + assert(remoteOutpoints.size == htlcs.bobToAlice.size) + remoteOutpoints.foreach { out => alice ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, htlcsTxsOut.head.copy(txIn = Seq(TxIn(out, Nil, 0)))) } + alice2blockchain.expectNoMessage(100 millis) + + checkPostSpliceState(f, spliceOutFee(f, capacity = 1_900_000.sat)) // done awaitCond(alice.stateName == CLOSED) assert(Helpers.Closing.isClosed(alice.stateData.asInstanceOf[DATA_CLOSING], None).exists(_.isInstanceOf[LocalClose])) } - test("force-close with multiple splices (previous active remote)") { f => + test("force-close with multiple splices (simple)") { f => + testForceCloseWithMultipleSplicesSimple(f) + } + + test("force-close with multiple splices (simple, quiescence)", Tag(ChannelStateTestsTags.Quiescence)) { f => + testForceCloseWithMultipleSplicesSimple(f) + } + + def testForceCloseWithMultipleSplicesPreviousActiveRemote(f: FixtureParam): Unit = { import f._ - initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat, pushAmount = 0 msat))) + val htlcs = setupHtlcs(f) + + initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat)), spliceOut_opt = Some(SpliceOut(100_000 sat, defaultSpliceOutScriptPubKey))) val fundingTx1 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localFundingStatus.signedTx_opt.get val watchConfirmed1 = alice2blockchain.expectMsgType[WatchFundingConfirmed] - initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat, pushAmount = 0 msat))) + initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat))) alice2blockchain.expectMsgType[WatchFundingConfirmed] alice2bob.expectNoMessage(100 millis) bob2alice.expectNoMessage(100 millis) @@ -1204,48 +1408,78 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik alice2bob.expectMsgType[Error] val aliceCommitTx2 = assertPublished(alice2blockchain, "commit-tx") assertPublished(alice2blockchain, "local-main-delayed") + // alice publishes her htlc timeout transactions + val htlcsTxsOut = htlcs.aliceToBob.map(_ => assertPublished(alice2blockchain, "htlc-timeout")) + htlcsTxsOut.foreach(tx => Transaction.correctlySpends(tx, Seq(aliceCommitTx2), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)) + alice2blockchain.expectMsgType[WatchTxConfirmed] alice2blockchain.expectMsgType[WatchTxConfirmed] - alice ! WatchFundingSpentTriggered(aliceCommitTx2) - alice2blockchain.expectNoMessage(100 millis) + // watch for all htlc outputs from local commit-tx to be spent + htlcs.aliceToBob.map(_ => alice2blockchain.expectMsgType[WatchOutputSpent]) + htlcs.bobToAlice.map(_ => alice2blockchain.expectMsgType[WatchOutputSpent]) // splice 1 confirms watchConfirmed1.replyTo ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx1) - alice2bob.forward(bob) - alice2blockchain.expectMsgType[WatchFundingSpent] - alice2blockchain.expectNoMessage(100 millis) + alice2blockchain.expectWatchFundingSpent(fundingTx1.txid) - // oops! remote commit for splice 1 is published - val bobCommitTx1 = bob.stateData.asInstanceOf[ChannelDataWithCommitments].commitments.active.find(_.fundingTxIndex == 1).get.localCommit.commitTxAndRemoteSig.commitTx.tx + // bob publishes his commit tx for splice 1 (which double-spends splice 2) + val bobCommitment1 = bob.stateData.asInstanceOf[ChannelDataWithCommitments].commitments.active.find(_.fundingTxIndex == 1).get + val bobCommitTx1 = bobCommitment1.fullySignedLocalCommitTx(bob.stateData.asInstanceOf[ChannelDataWithCommitments].commitments.params, bob.underlyingActor.keyManager).tx + Transaction.correctlySpends(bobCommitTx1, Seq(fundingTx1), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) alice ! WatchFundingSpentTriggered(bobCommitTx1) val watchAlternativeConfirmed = alice2blockchain.expectMsgType[WatchAlternativeCommitTxConfirmed] alice2blockchain.expectNoMessage(100 millis) - // remote commit tx confirms watchAlternativeConfirmed.replyTo ! WatchAlternativeCommitTxConfirmedTriggered(BlockHeight(400000), 42, bobCommitTx1) // we're back to the normal handling of remote commit - val claimMain = alice2blockchain.expectMsgType[PublishFinalTx].tx - val watchConfirmedRemoteCommit = alice2blockchain.expectMsgType[WatchTxConfirmed] - assert(watchConfirmedRemoteCommit.txId == bobCommitTx1.txid) + val claimMain = assertPublished(alice2blockchain, "remote-main") + val htlcsTxsOut1 = htlcs.aliceToBob.map(_ => assertPublished(alice2blockchain, "claim-htlc-timeout")) + htlcsTxsOut1.foreach(tx => Transaction.correctlySpends(tx, Seq(bobCommitTx1), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)) + val watchConfirmedRemoteCommit = alice2blockchain.expectWatchTxConfirmed(bobCommitTx1.txid) // this one fires immediately, tx is already confirmed watchConfirmedRemoteCommit.replyTo ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, bobCommitTx1) - val watchConfirmedClaimMain = alice2blockchain.expectMsgType[WatchTxConfirmed] - // claim-main tx confirms + val watchConfirmedClaimMain = alice2blockchain.expectWatchTxConfirmed(claimMain.txid) watchConfirmedClaimMain.replyTo ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, claimMain) + // alice's htlc-timeout transactions confirm + val watchHtlcsOut1 = htlcs.aliceToBob.map(_ => alice2blockchain.expectMsgType[WatchOutputSpent]) + htlcs.bobToAlice.map(_ => alice2blockchain.expectMsgType[WatchOutputSpent]) + watchHtlcsOut1.zip(htlcsTxsOut1).foreach { case (watch, tx) => watch.replyTo ! WatchOutputSpentTriggered(tx) } + htlcsTxsOut1.foreach { tx => + alice2blockchain.expectWatchTxConfirmed(tx.txid) + alice ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, tx) + } + + // bob's htlc-timeout transactions confirm + bobCommitment1.localCommit.htlcTxsAndRemoteSigs.foreach(txAndSig => alice ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, txAndSig.htlcTx.tx)) + alice2blockchain.expectNoMessage(100 millis) + + checkPostSpliceState(f, spliceOutFee = 0.sat) + // done awaitCond(alice.stateName == CLOSED) assert(Helpers.Closing.isClosed(alice.stateData.asInstanceOf[DATA_CLOSING], None).exists(_.isInstanceOf[RemoteClose])) } - test("force-close with multiple splices (previous active revoked)") { f => + test("force-close with multiple splices (previous active remote)") { f => + testForceCloseWithMultipleSplicesPreviousActiveRemote(f) + } + + test("force-close with multiple splices (previous active remote, quiescence)", Tag(ChannelStateTestsTags.Quiescence)) { f => + testForceCloseWithMultipleSplicesPreviousActiveRemote(f) + } + + def testForceCloseWithMultipleSplicesPreviousActiveRevoked(f: FixtureParam): Unit = { import f._ - initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat, pushAmount = 0 msat))) + val htlcs = setupHtlcs(f) + + // pay 10_000_000 msat to bob that will be paid back to alice after the splices + initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat, pushAmount = 10_000_000 msat)), spliceOut_opt = Some(SpliceOut(100_000 sat, defaultSpliceOutScriptPubKey))) val fundingTx1 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localFundingStatus.signedTx_opt.get - alice2blockchain.expectMsgType[WatchFundingConfirmed] + alice2blockchain.expectWatchFundingConfirmed(fundingTx1.txid) // remember bob's commitment for later val bobCommit1 = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.active.head initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat, pushAmount = 0 msat))) @@ -1263,60 +1497,77 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik // funding tx1 confirms alice ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx1) - // alice puts a watch-spent - alice2blockchain.expectMsgType[WatchFundingSpent] + alice2blockchain.expectWatchFundingSpent(fundingTx1.txid) // bob publishes a revoked commitment for fundingTx1! val bobRevokedCommitTx = bobCommit1.localCommit.commitTxAndRemoteSig.commitTx.tx alice ! WatchFundingSpentTriggered(bobRevokedCommitTx) - // alice watches bob's revoked commit tx, and force-closes with latest commitment + // alice watches bob's revoked commit tx, and force-closes with her latest commitment assert(alice2blockchain.expectMsgType[WatchAlternativeCommitTxConfirmed].txId == bobRevokedCommitTx.txid) val aliceCommitTx2 = assertPublished(alice2blockchain, "commit-tx") val claimMainDelayed2 = assertPublished(alice2blockchain, "local-main-delayed") - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceCommitTx2.txid) - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == claimMainDelayed2.txid) - alice2blockchain.expectNoMessage(100 millis) + htlcs.aliceToBob.map(_ => assertPublished(alice2blockchain, "htlc-timeout")) + alice2blockchain.expectWatchTxConfirmed(aliceCommitTx2.txid) + alice2blockchain.expectWatchTxConfirmed(claimMainDelayed2.txid) + htlcs.aliceToBob.map(_ => alice2blockchain.expectMsgType[WatchOutputSpent]) + htlcs.bobToAlice.map(_ => alice2blockchain.expectMsgType[WatchOutputSpent]) // bob's revoked tx wins alice ! WatchAlternativeCommitTxConfirmedTriggered(BlockHeight(400000), 42, bobRevokedCommitTx) // alice reacts by punishing bob - val aliceClaimMain1 = assertPublished(alice2blockchain, "remote-main") - val aliceMainPenalty1 = assertPublished(alice2blockchain, "main-penalty") - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobRevokedCommitTx.txid) - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceClaimMain1.txid) + val aliceClaimMain = assertPublished(alice2blockchain, "remote-main") + val aliceMainPenalty = assertPublished(alice2blockchain, "main-penalty") + val aliceHtlcsPenalty = htlcs.aliceToBob.map(_ => assertPublished(alice2blockchain, "htlc-penalty")) ++ htlcs.bobToAlice.map(_ => assertPublished(alice2blockchain, "htlc-penalty")) + aliceHtlcsPenalty.foreach(tx => Transaction.correctlySpends(tx, Seq(bobRevokedCommitTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)) + alice2blockchain.expectWatchTxConfirmed(bobRevokedCommitTx.txid) + alice2blockchain.expectWatchTxConfirmed(aliceClaimMain.txid) assert(alice2blockchain.expectMsgType[WatchOutputSpent].txId == bobRevokedCommitTx.txid) + aliceHtlcsPenalty.map(_ => alice2blockchain.expectMsgType[WatchOutputSpent]) alice2blockchain.expectNoMessage(100 millis) - // both tx confirm + // alice's main penalty txs confirm alice ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, bobRevokedCommitTx) - alice ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, aliceClaimMain1) - alice ! WatchOutputSpentTriggered(aliceMainPenalty1) - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceMainPenalty1.txid) - alice ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, aliceMainPenalty1) + alice ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, aliceClaimMain) + alice ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, aliceMainPenalty) + // alice's htlc-penalty txs confirm + aliceHtlcsPenalty.foreach { tx => alice ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, tx) } + + checkPostSpliceState(f, spliceOutFee = 0.sat) // done awaitCond(alice.stateName == CLOSED) assert(Helpers.Closing.isClosed(alice.stateData.asInstanceOf[DATA_CLOSING], None).exists(_.isInstanceOf[RevokedClose])) } - test("force-close with multiple splices (inactive remote)", Tag(ChannelStateTestsTags.ZeroConf), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + test("force-close with multiple splices (previous active revoked)") { f => + testForceCloseWithMultipleSplicesPreviousActiveRevoked(f) + } + + test("force-close with multiple splices (previous active revoked, quiescent)", Tag(ChannelStateTestsTags.Quiescence)) { f => + testForceCloseWithMultipleSplicesPreviousActiveRevoked(f) + } + + def testForceCloseWithMultipleSplicesInactiveRemote(f: FixtureParam): Unit = { import f._ - initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat, pushAmount = 0 msat))) + val htlcs = setupHtlcs(f) + + // pay 10_000_000 msat to bob that will be paid back to alice after the splices + initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat, pushAmount = 10_000_000 msat)), spliceOut_opt = Some(SpliceOut(100_000 sat, defaultSpliceOutScriptPubKey))) val fundingTx1 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localFundingStatus.signedTx_opt.get - alice2blockchain.expectMsgType[WatchPublished] - bob2blockchain.expectMsgType[WatchPublished] + alice2blockchain.expectWatchPublished(fundingTx1.txid) + bob2blockchain.expectWatchPublished(fundingTx1.txid) // splice 1 gets published alice ! WatchPublishedTriggered(fundingTx1) bob ! WatchPublishedTriggered(fundingTx1) - alice2blockchain.expectMsgType[WatchFundingConfirmed] - bob2blockchain.expectMsgType[WatchFundingConfirmed] + alice2blockchain.expectWatchFundingConfirmed(fundingTx1.txid) + bob2blockchain.expectWatchFundingConfirmed(fundingTx1.txid) alice2bob.expectMsgType[SpliceLocked] alice2bob.forward(bob) bob2alice.expectMsgType[SpliceLocked] bob2alice.forward(alice) - // bob makes a payment + // bob makes a payment which is applied to splice 1 val (preimage, add) = addHtlc(10_000_000 msat, bob, alice, bob2alice, alice2bob) crossSign(bob, alice, bob2alice, alice2bob) fulfillHtlc(add.id, preimage, alice, bob, alice2bob, bob2alice) @@ -1325,25 +1576,22 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik // remember bob's commitment for later val bobCommit1 = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.active.head - initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat, pushAmount = 0 msat))) + initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat))) val fundingTx2 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localFundingStatus.signedTx_opt.get - alice2blockchain.expectMsgType[WatchPublished] - bob2blockchain.expectMsgType[WatchPublished] + alice2blockchain.expectWatchPublished(fundingTx2.txid) + bob2blockchain.expectWatchPublished(fundingTx2.txid) // splice 2 gets published alice ! WatchPublishedTriggered(fundingTx2) bob ! WatchPublishedTriggered(fundingTx2) - alice2blockchain.expectMsgType[WatchFundingConfirmed] - bob2blockchain.expectMsgType[WatchFundingConfirmed] + alice2blockchain.expectWatchFundingConfirmed(fundingTx2.txid) + bob2blockchain.expectWatchFundingConfirmed(fundingTx2.txid) alice2bob.expectMsgType[SpliceLocked] alice2bob.forward(bob) bob2alice.expectMsgType[SpliceLocked] bob2alice.forward(alice) // splice 1 is now inactive - assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.inactive.exists(_.fundingTxId == fundingTx1.txid)) - assert(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.inactive.exists(_.fundingTxId == fundingTx1.txid)) - alice2bob.expectNoMessage(100 millis) - bob2alice.expectNoMessage(100 millis) - alice2blockchain.expectNoMessage(100 millis) + awaitCond(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.inactive.exists(_.fundingTxId == fundingTx1.txid)) + awaitCond(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.inactive.exists(_.fundingTxId == fundingTx1.txid)) // we now have two unconfirmed splices, one active and one inactive, and the inactive initial funding assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.active.size == 1) assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.inactive.size == 2) @@ -1353,20 +1601,25 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik // funding tx1 confirms alice ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx1) // alice puts a watch-spent and prunes the initial funding - alice2blockchain.expectMsgType[WatchFundingSpent] + alice2blockchain.expectWatchFundingSpent(fundingTx1.txid) assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.active.size == 1) awaitCond(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.inactive.size == 1) - // bob publishes his latest commitment for fundingTx1 + // bob publishes his latest (inactive) commitment for fundingTx1 val bobCommitTx1 = bobCommit1.localCommit.commitTxAndRemoteSig.commitTx.tx alice ! WatchFundingSpentTriggered(bobCommitTx1) - // alice watches bob's revoked commit tx, and force-closes with latest commitment + // alice watches bob's commit tx, and force-closes with her latest commitment assert(alice2blockchain.expectMsgType[WatchAlternativeCommitTxConfirmed].txId == bobCommitTx1.txid) val aliceCommitTx2 = assertPublished(alice2blockchain, "commit-tx") assertPublished(alice2blockchain, "local-anchor") val claimMainDelayed2 = assertPublished(alice2blockchain, "local-main-delayed") - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceCommitTx2.txid) - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == claimMainDelayed2.txid) - alice2blockchain.expectMsgType[WatchOutputSpent] + val htlcsTxsOut = htlcs.aliceToBob.map(_ => assertPublished(alice2blockchain, "htlc-timeout")) + htlcsTxsOut.foreach(tx => Transaction.correctlySpends(tx, Seq(aliceCommitTx2), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)) + + alice2blockchain.expectWatchTxConfirmed(aliceCommitTx2.txid) + alice2blockchain.expectWatchTxConfirmed(claimMainDelayed2.txid) + alice2blockchain.expectMsgType[WatchOutputSpent] // local-anchor + htlcs.aliceToBob.map(_ => assert(alice2blockchain.expectMsgType[WatchOutputSpent].txId == aliceCommitTx2.txid)) + htlcs.bobToAlice.map(_ => assert(alice2blockchain.expectMsgType[WatchOutputSpent].txId == aliceCommitTx2.txid)) alice2blockchain.expectNoMessage(100 millis) // bob's remote tx wins @@ -1374,65 +1627,83 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik // we're back to the normal handling of remote commit assert(alice2blockchain.expectMsgType[PublishReplaceableTx].txInfo.isInstanceOf[ClaimLocalAnchorOutputTx]) val claimMain = alice2blockchain.expectMsgType[PublishFinalTx].tx - val watchConfirmedRemoteCommit = alice2blockchain.expectMsgType[WatchTxConfirmed] - assert(watchConfirmedRemoteCommit.txId == bobCommitTx1.txid) + val claimHtlcsTxsOut = htlcs.aliceToBob.map(_ => assertPublished(alice2blockchain, "claim-htlc-timeout")) + claimHtlcsTxsOut.foreach(tx => Transaction.correctlySpends(tx, Seq(bobCommitTx1), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)) + + val watchConfirmedRemoteCommit = alice2blockchain.expectWatchTxConfirmed(bobCommitTx1.txid) // this one fires immediately, tx is already confirmed watchConfirmedRemoteCommit.replyTo ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, bobCommitTx1) - val watchConfirmedClaimMain = alice2blockchain.expectMsgType[WatchTxConfirmed] - - // claim-main tx confirms + val watchConfirmedClaimMain = alice2blockchain.expectWatchTxConfirmed(claimMain.txid) watchConfirmedClaimMain.replyTo ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, claimMain) + // watch alice and bob's htlcs and publish alice's htlcs-timeout txs + htlcs.aliceToBob.map(_ => assert(alice2blockchain.expectMsgType[WatchOutputSpent].txId == bobCommitTx1.txid)) + htlcs.bobToAlice.map(_ => assert(alice2blockchain.expectMsgType[WatchOutputSpent].txId == bobCommitTx1.txid)) + claimHtlcsTxsOut.foreach { tx => alice ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, tx) } + + // publish bob's htlc-timeout txs + bobCommit1.localCommit.htlcTxsAndRemoteSigs.foreach(txAndSigs => alice ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, txAndSigs.htlcTx.tx)) + alice2blockchain.expectNoMessage(100 millis) + + // alice's final commitment includes the initial htlcs, but not bob's payment + checkPostSpliceState(f, spliceOutFee = 0.sat) // done awaitCond(alice.stateName == CLOSED) 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 remote)", Tag(ChannelStateTestsTags.ZeroConf), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + testForceCloseWithMultipleSplicesInactiveRemote(f) + } + + test("force-close with multiple splices (inactive remote, quiescence)", Tag(ChannelStateTestsTags.Quiescence), Tag(ChannelStateTestsTags.ZeroConf), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + testForceCloseWithMultipleSplicesInactiveRemote(f) + } + + def testForceCloseWithMultipleSplicesInactiveRevoked(f: FixtureParam): Unit = { import f._ - initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat, pushAmount = 0 msat))) + val htlcs = setupHtlcs(f) + + initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat)), spliceOut_opt = Some(SpliceOut(100_000 sat, defaultSpliceOutScriptPubKey))) val fundingTx1 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localFundingStatus.signedTx_opt.get - alice2blockchain.expectMsgType[WatchPublished] - bob2blockchain.expectMsgType[WatchPublished] + alice2blockchain.expectWatchPublished(fundingTx1.txid) + bob2blockchain.expectWatchPublished(fundingTx1.txid) // remember bob's commitment for later val bobCommit1 = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.active.head // splice 1 gets published alice ! WatchPublishedTriggered(fundingTx1) bob ! WatchPublishedTriggered(fundingTx1) - alice2blockchain.expectMsgType[WatchFundingConfirmed] - bob2blockchain.expectMsgType[WatchFundingConfirmed] + alice2blockchain.expectWatchFundingConfirmed(fundingTx1.txid) + bob2blockchain.expectWatchFundingConfirmed(fundingTx1.txid) alice2bob.expectMsgType[SpliceLocked] alice2bob.forward(bob) bob2alice.expectMsgType[SpliceLocked] bob2alice.forward(alice) - initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat, pushAmount = 0 msat))) + initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat))) val fundingTx2 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localFundingStatus.signedTx_opt.get - alice2blockchain.expectMsgType[WatchPublished] - bob2blockchain.expectMsgType[WatchPublished] + alice2blockchain.expectWatchPublished(fundingTx2.txid) + bob2blockchain.expectWatchPublished(fundingTx2.txid) // splice 2 gets published alice ! WatchPublishedTriggered(fundingTx2) bob ! WatchPublishedTriggered(fundingTx2) - alice2blockchain.expectMsgType[WatchFundingConfirmed] - bob2blockchain.expectMsgType[WatchFundingConfirmed] + alice2blockchain.expectWatchFundingConfirmed(fundingTx2.txid) + bob2blockchain.expectWatchFundingConfirmed(fundingTx2.txid) alice2bob.expectMsgType[SpliceLocked] alice2bob.forward(bob) bob2alice.expectMsgType[SpliceLocked] bob2alice.forward(alice) // splice 1 is now inactive - assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.inactive.exists(_.fundingTxId == fundingTx1.txid)) - assert(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.inactive.exists(_.fundingTxId == fundingTx1.txid)) - alice2bob.expectNoMessage(100 millis) - bob2alice.expectNoMessage(100 millis) - alice2blockchain.expectNoMessage(100 millis) + awaitCond(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.inactive.exists(_.fundingTxId == fundingTx1.txid)) + awaitCond(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.inactive.exists(_.fundingTxId == fundingTx1.txid)) // we now have two unconfirmed splices, one active and one inactive, and the inactive initial funding assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.active.size == 1) assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.inactive.size == 2) assert(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.active.size == 1) assert(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.inactive.size == 2) - // bob makes a payment + // bob makes a payment that is only applied to splice 2 val (preimage, add) = addHtlc(10_000_000 msat, bob, alice, bob2alice, alice2bob) crossSign(bob, alice, bob2alice, alice2bob) fulfillHtlc(add.id, preimage, alice, bob, alice2bob, bob2alice) @@ -1441,49 +1712,67 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik // funding tx1 confirms alice ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx1) // alice puts a watch-spent and prunes the initial funding - alice2blockchain.expectMsgType[WatchFundingSpent] + alice2blockchain.expectWatchFundingSpent(fundingTx1.txid) assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.active.size == 1) awaitCond(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.inactive.size == 1) - // bob publishes a revoked commitment for fundingTx1! + // bob publishes his latest commitment for fundingTx1, which is now revoked val bobRevokedCommitTx = bobCommit1.localCommit.commitTxAndRemoteSig.commitTx.tx alice ! WatchFundingSpentTriggered(bobRevokedCommitTx) - // alice watches bob's revoked commit tx, and force-closes with latest commitment + // alice watches bob's revoked commit tx, and force-closes with her latest commitment assert(alice2blockchain.expectMsgType[WatchAlternativeCommitTxConfirmed].txId == bobRevokedCommitTx.txid) val aliceCommitTx2 = assertPublished(alice2blockchain, "commit-tx") assertPublished(alice2blockchain, "local-anchor") val claimMainDelayed2 = assertPublished(alice2blockchain, "local-main-delayed") - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceCommitTx2.txid) - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == claimMainDelayed2.txid) - alice2blockchain.expectMsgType[WatchOutputSpent] + htlcs.aliceToBob.map(_ => assertPublished(alice2blockchain, "htlc-timeout")) + + alice2blockchain.expectWatchTxConfirmed(aliceCommitTx2.txid) + alice2blockchain.expectWatchTxConfirmed(claimMainDelayed2.txid) + alice2blockchain.expectMsgType[WatchOutputSpent] // local-anchor + htlcs.aliceToBob.foreach(_ => assert(alice2blockchain.expectMsgType[WatchOutputSpent].txId == aliceCommitTx2.txid)) + htlcs.bobToAlice.foreach(_ => assert(alice2blockchain.expectMsgType[WatchOutputSpent].txId == aliceCommitTx2.txid)) alice2blockchain.expectNoMessage(100 millis) // bob's revoked tx wins alice ! WatchAlternativeCommitTxConfirmedTriggered(BlockHeight(400000), 42, bobRevokedCommitTx) // alice reacts by punishing bob - val aliceClaimMain1 = assertPublished(alice2blockchain, "remote-main-delayed") - val aliceMainPenalty1 = assertPublished(alice2blockchain, "main-penalty") - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobRevokedCommitTx.txid) - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceClaimMain1.txid) - assert(alice2blockchain.expectMsgType[WatchOutputSpent].txId == bobRevokedCommitTx.txid) + val aliceClaimMain = assertPublished(alice2blockchain, "remote-main-delayed") + val aliceMainPenalty = assertPublished(alice2blockchain, "main-penalty") + val aliceHtlcsPenalty = htlcs.aliceToBob.map(_ => assertPublished(alice2blockchain, "htlc-penalty")) ++ htlcs.bobToAlice.map(_ => assertPublished(alice2blockchain, "htlc-penalty")) + + alice2blockchain.expectWatchTxConfirmed(bobRevokedCommitTx.txid) + alice2blockchain.expectWatchTxConfirmed(aliceClaimMain.txid) + assert(alice2blockchain.expectMsgType[WatchOutputSpent].txId == bobRevokedCommitTx.txid) // main-penalty + aliceHtlcsPenalty.map(_ => assert(alice2blockchain.expectMsgType[WatchOutputSpent].txId == bobRevokedCommitTx.txid)) alice2blockchain.expectNoMessage(100 millis) - // both tx confirm + // all penalty txs confirm alice ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, bobRevokedCommitTx) - alice ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, aliceClaimMain1) - alice ! WatchOutputSpentTriggered(aliceMainPenalty1) - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceMainPenalty1.txid) - alice ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, aliceMainPenalty1) + alice ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, aliceClaimMain) + alice ! WatchOutputSpentTriggered(aliceMainPenalty) + alice2blockchain.expectWatchTxConfirmed(aliceMainPenalty.txid) + alice ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, aliceMainPenalty) + aliceHtlcsPenalty.foreach { tx => alice ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, tx) } + + // alice's final commitment includes the initial htlcs, but not bob's payment + checkPostSpliceState(f, spliceOutFee = 0 sat) // done awaitCond(alice.stateName == CLOSED) assert(Helpers.Closing.isClosed(alice.stateData.asInstanceOf[DATA_CLOSING], None).exists(_.isInstanceOf[RevokedClose])) } + test("force-close with multiple splices (inactive revoked)", Tag(ChannelStateTestsTags.ZeroConf), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + testForceCloseWithMultipleSplicesInactiveRevoked(f) + } + + test("force-close with multiple splices (inactive revoked, quiescence)", Tag(ChannelStateTestsTags.Quiescence), Tag(ChannelStateTestsTags.ZeroConf), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + testForceCloseWithMultipleSplicesInactiveRevoked(f) + } + test("put back watches after restart") { f => import f._ - val commitments0 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest - val fundingTx0 = commitments0.localFundingStatus.signedTx_opt.get + val fundingTx0 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localFundingStatus.signedTx_opt.get val (fundingTx1, fundingTx2) = setup2Splices(f) val (aliceNodeParams, bobNodeParams) = (alice.underlyingActor.nodeParams, bob.underlyingActor.nodeParams) @@ -1503,7 +1792,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik alice2blockchain.expectMsgType[SetChannelId] alice2blockchain.expectWatchFundingConfirmed(fundingTx2.txid) alice2blockchain.expectWatchFundingConfirmed(fundingTx1.txid) - alice2blockchain.expectWatchFundingSpent(fundingTx0.txid, Some(Set(fundingTx1.txid, commitments0.localCommit.commitTxAndRemoteSig.commitTx.tx.txid, commitments0.remoteCommit.txid))) + alice2blockchain.expectWatchFundingSpent(fundingTx0.txid) alice2blockchain.expectNoMessage(100 millis) val bob2 = TestFSMRef(new Channel(bobNodeParams, wallet, aliceNodeParams.nodeId, bob2blockchain.ref, TestProbe().ref, FakeTxPublisherFactory(bob2blockchain)), bobPeer) @@ -1511,15 +1800,14 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik bob2blockchain.expectMsgType[SetChannelId] bob2blockchain.expectWatchFundingConfirmed(fundingTx2.txid) bob2blockchain.expectWatchFundingConfirmed(fundingTx1.txid) - bob2blockchain.expectWatchFundingSpent(fundingTx0.txid, Some(Set(fundingTx1.txid, commitments0.localCommit.commitTxAndRemoteSig.commitTx.tx.txid, commitments0.remoteCommit.txid))) + bob2blockchain.expectWatchFundingSpent(fundingTx0.txid) bob2blockchain.expectNoMessage(100 millis) } test("put back watches after restart (inactive)", Tag(ChannelStateTestsTags.ZeroConf), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => import f._ - val commitments0 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest - val fundingTx0 = commitments0.localFundingStatus.signedTx_opt.get + val fundingTx0 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localFundingStatus.signedTx_opt.get alice ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx0) bob ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, fundingTx0) @@ -1563,7 +1851,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik alice2blockchain.expectMsgType[SetChannelId] alice2blockchain.expectWatchPublished(fundingTx2.txid) alice2blockchain.expectWatchFundingConfirmed(fundingTx1.txid) - alice2blockchain.expectWatchFundingSpent(fundingTx0.txid, Some(Set(fundingTx1.txid, commitments0.localCommit.commitTxAndRemoteSig.commitTx.tx.txid, commitments0.remoteCommit.txid))) + alice2blockchain.expectWatchFundingSpent(fundingTx0.txid) alice2blockchain.expectNoMessage(100 millis) val bob2 = TestFSMRef(new Channel(bobNodeParams, wallet, aliceNodeParams.nodeId, bob2blockchain.ref, TestProbe().ref, FakeTxPublisherFactory(bob2blockchain)), bobPeer) @@ -1571,8 +1859,104 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik bob2blockchain.expectMsgType[SetChannelId] bob2blockchain.expectWatchPublished(fundingTx2.txid) bob2blockchain.expectWatchFundingConfirmed(fundingTx1.txid) - bob2blockchain.expectWatchFundingSpent(fundingTx0.txid, Some(Set(fundingTx1.txid, commitments0.localCommit.commitTxAndRemoteSig.commitTx.tx.txid, commitments0.remoteCommit.txid))) + bob2blockchain.expectWatchFundingSpent(fundingTx0.txid) bob2blockchain.expectNoMessage(100 millis) } + test("recv CMD_SPLICE (splice-in + splice-out) with pre and post splice htlcs", Tag(ChannelStateTestsTags.Quiescence)) { f => + import f._ + val htlcs = setupHtlcs(f) + + initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat, pushAmount = 10_000_000 msat)), spliceOut_opt = Some(SpliceOut(100_000 sat, defaultSpliceOutScriptPubKey))) + + // bob sends an HTLC that is applied to both commitments + val (preimage, add) = addHtlc(10_000_000 msat, bob, alice, bob2alice, alice2bob) + crossSign(bob, alice, bob2alice, alice2bob) + val aliceCommitments1 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments + aliceCommitments1.active.foreach { c => + val commitTx = c.fullySignedLocalCommitTx(aliceCommitments1.params, alice.underlyingActor.keyManager).tx + Transaction.correctlySpends(commitTx, Map(c.commitInput.outPoint -> c.commitInput.txOut), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + } + val bobCommitments1 = bob.stateData.asInstanceOf[DATA_NORMAL].commitments + bobCommitments1.active.foreach { c => + val commitTx = c.fullySignedLocalCommitTx(bobCommitments1.params, bob.underlyingActor.keyManager).tx + Transaction.correctlySpends(commitTx, Map(c.commitInput.outPoint -> c.commitInput.txOut), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + } + + // alice fulfills that HTLC in both commitments + fulfillHtlc(add.id, preimage, alice, bob, alice2bob, bob2alice) + crossSign(alice, bob, alice2bob, bob2alice) + val aliceCommitments2 = alice.stateData.asInstanceOf[DATA_NORMAL].commitments + aliceCommitments2.active.foreach { c => + val commitTx = c.fullySignedLocalCommitTx(aliceCommitments2.params, alice.underlyingActor.keyManager).tx + Transaction.correctlySpends(commitTx, Map(c.commitInput.outPoint -> c.commitInput.txOut), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + } + val bobCommitments2 = bob.stateData.asInstanceOf[DATA_NORMAL].commitments + bobCommitments2.active.foreach { c => + val commitTx = c.fullySignedLocalCommitTx(bobCommitments2.params, bob.underlyingActor.keyManager).tx + Transaction.correctlySpends(commitTx, Map(c.commitInput.outPoint -> c.commitInput.txOut), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + } + + resolveHtlcs(f, htlcs, spliceOutFee = 0.sat) + } + + test("recv CMD_SPLICE (splice-in + splice-out) with pending htlcs, resolved after splice locked", Tag(ChannelStateTestsTags.Quiescence), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + import f._ + + val htlcs = setupHtlcs(f) + + val spliceTx = initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat)), spliceOut_opt = Some(SpliceOut(100_000 sat, defaultSpliceOutScriptPubKey))) + + alice ! WatchFundingConfirmedTriggered(BlockHeight(42), 0, spliceTx) + alice2bob.expectMsgType[SpliceLocked] + alice2bob.forward(bob) + 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) + + resolveHtlcs(f, htlcs, spliceOutFee = 0.sat) + } + + test("recv multiple CMD_SPLICE (splice-in, splice-out, quiescence)", Tag(ChannelStateTestsTags.Quiescence)) { f => + val htlcs = setupHtlcs(f) + + initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat))) + initiateSplice(f, spliceOut_opt = Some(SpliceOut(100_000 sat, defaultSpliceOutScriptPubKey))) + + resolveHtlcs(f, htlcs, spliceOutFee = spliceOutFee(f, capacity = 1_900_000.sat)) + } + + test("recv invalid htlc signatures during splice-in", Tag(ChannelStateTestsTags.Quiescence)) { f => + import f._ + + val htlcs = setupHtlcs(f) + initiateSpliceWithoutSigs(f, spliceIn_opt = Some(SpliceIn(500_000 sat))) + val commitSigAlice = alice2bob.expectMsgType[CommitSig] + assert(commitSigAlice.htlcSignatures.size == 4) + val commitSigBob = bob2alice.expectMsgType[CommitSig] + assert(commitSigBob.htlcSignatures.size == 4) + bob2alice.forward(alice, commitSigBob) + + alice2bob.forward(bob, commitSigAlice.copy(htlcSignatures = commitSigAlice.htlcSignatures.reverse)) + val txAbortBob = bob2alice.expectMsgType[TxAbort] + bob2alice.forward(alice, txAbortBob) + val txAbortAlice = alice2bob.expectMsgType[TxAbort] + alice2bob.forward(bob, txAbortAlice) + + // resolve pre-splice HTLCs after aborting the splice attempt + val Seq((preimage1a, htlc1a), (preimage2a, htlc2a)) = htlcs.aliceToBob + val Seq((preimage1b, htlc1b), (preimage2b, htlc2b)) = htlcs.bobToAlice + fulfillHtlc(htlc1a.id, preimage1a, bob, alice, bob2alice, alice2bob) + fulfillHtlc(htlc2a.id, preimage2a, bob, alice, bob2alice, alice2bob) + crossSign(bob, alice, bob2alice, alice2bob) + fulfillHtlc(htlc1b.id, preimage1b, alice, bob, alice2bob, bob2alice) + fulfillHtlc(htlc2b.id, preimage2b, alice, bob, alice2bob, bob2alice) + crossSign(alice, bob, alice2bob, bob2alice) + val finalState = alice.stateData.asInstanceOf[DATA_NORMAL] + assert(finalState.commitments.latest.localCommit.spec.toLocal == 805_000_000.msat) + assert(finalState.commitments.latest.localCommit.spec.toRemote == 695_000_000.msat) + } + } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/testutils/PimpTestProbe.scala b/eclair-core/src/test/scala/fr/acinq/eclair/testutils/PimpTestProbe.scala index 440085164b..aa547661db 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/testutils/PimpTestProbe.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/testutils/PimpTestProbe.scala @@ -3,7 +3,7 @@ package fr.acinq.eclair.testutils import akka.testkit.TestProbe import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi} import fr.acinq.eclair.MilliSatoshi -import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{WatchFundingConfirmed, WatchFundingSpent, WatchPublished} +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{WatchFundingConfirmed, WatchFundingSpent, WatchPublished, WatchTxConfirmed} import fr.acinq.eclair.channel.AvailableBalanceChanged import org.scalatest.Assertions @@ -31,6 +31,9 @@ case class PimpTestProbe(probe: TestProbe) extends Assertions { def expectWatchFundingConfirmed(txid: ByteVector32): WatchFundingConfirmed = expectMsgTypeHaving[WatchFundingConfirmed](w => assert(w.txId == txid, "txid")) + def expectWatchTxConfirmed(txid: ByteVector32): WatchTxConfirmed = + expectMsgTypeHaving[WatchTxConfirmed](w => assert(w.txId == txid, "txid")) + def expectWatchPublished(txid: ByteVector32): WatchPublished = expectMsgTypeHaving[WatchPublished](w => assert(w.txId == txid, "txid")) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4Spec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4Spec.scala index 4e18d08786..fb79721b24 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4Spec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4Spec.scala @@ -123,7 +123,7 @@ class ChannelCodecs4Spec extends AnyFunSuite { val fundingInput = InputInfo(OutPoint(randomBytes32(), 3), TxOut(175_000 sat, Script.pay2wpkh(randomKey().publicKey)), Nil) val fundingTx = SharedTransaction( sharedInput_opt = None, - sharedOutput = InteractiveTxBuilder.Output.Shared(UInt64(8), ByteVector.empty, 100_000_600 msat, 74_000_400 msat), + sharedOutput = InteractiveTxBuilder.Output.Shared(UInt64(8), ByteVector.empty, 100_000_600 msat, 74_000_400 msat, 0 msat), localInputs = Nil, remoteInputs = Nil, localOutputs = Nil, remoteOutputs = Nil, lockTime = 0