diff --git a/docs/release-notes/eclair-vnext.md b/docs/release-notes/eclair-vnext.md index 098c63977f..92928d9e0f 100644 --- a/docs/release-notes/eclair-vnext.md +++ b/docs/release-notes/eclair-vnext.md @@ -13,6 +13,18 @@ Every node advertizes the rates at which they sell their liquidity, and buyers c The liquidity ads specification is still under review and will likely change. This feature isn't meant to be used on mainnet yet and is thus disabled by default. +### Simplified mutual close + +This release includes support for the latest [mutual close protocol](https://github.com/lightning/bolts/pull/1096). +This protocol allows both channel participants to decide exactly how much fees they're willing to pay to close the channel. +Each participant obtains a channel closing transaction where they are paying the fees. + +Once closing transactions are broadcast, they can be RBF-ed by calling the `close` RPC again with a higher feerate: + +```sh +./eclair-cli close --channelId= --preferredFeerateSatByte= +``` + ### Update minimal version of Bitcoin Core With this release, eclair requires using Bitcoin Core 27.1. diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/balance/CheckBalance.scala b/eclair-core/src/main/scala/fr/acinq/eclair/balance/CheckBalance.scala index 03ce950797..c4bb4db38f 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/balance/CheckBalance.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/balance/CheckBalance.scala @@ -213,6 +213,7 @@ object CheckBalance { case (r, d: DATA_NORMAL) => r.modify(_.normal).using(updateMainAndHtlcBalance(d.commitments, knownPreimages)) case (r, d: DATA_SHUTDOWN) => r.modify(_.shutdown).using(updateMainAndHtlcBalance(d.commitments, knownPreimages)) case (r, d: DATA_NEGOTIATING) => r.modify(_.negotiating).using(updateMainBalance(d.commitments.latest.localCommit)) + case (r, d: DATA_NEGOTIATING_SIMPLE) => r.modify(_.negotiating).using(updateMainBalance(d.commitments.latest.localCommit)) case (r, d: DATA_CLOSING) => Closing.isClosingTypeAlreadyKnown(d) match { case None if d.mutualClosePublished.nonEmpty && d.localCommitPublished.isEmpty && d.remoteCommitPublished.isEmpty && d.nextRemoteCommitPublished.isEmpty && d.revokedCommitPublished.isEmpty => diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala index 330eb34670..932c493979 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala @@ -72,6 +72,7 @@ case object WAIT_FOR_DUAL_FUNDING_READY extends ChannelState case object NORMAL extends ChannelState case object SHUTDOWN extends ChannelState case object NEGOTIATING extends ChannelState +case object NEGOTIATING_SIMPLE extends ChannelState case object CLOSING extends ChannelState case object CLOSED extends ChannelState case object OFFLINE extends ChannelState @@ -653,6 +654,15 @@ final case class DATA_NEGOTIATING(commitments: Commitments, require(closingTxProposed.nonEmpty, "there must always be a list for the current negotiation") require(!commitments.params.localParams.paysClosingFees || closingTxProposed.forall(_.nonEmpty), "initiator must have at least one closing signature for every negotiation attempt because it initiates the closing") } +final case class DATA_NEGOTIATING_SIMPLE(commitments: Commitments, + localShutdown: Shutdown, remoteShutdown: Shutdown, + // Closing transactions we created, where we pay the fees (unsigned). + proposedClosingTxs: List[ClosingTxs], + // Closing transactions we published: this contains our local transactions for + // which they sent a signature, and their closing transactions that we signed. + publishedClosingTxs: List[ClosingTx]) extends ChannelDataWithCommitments { + def findClosingTx(tx: Transaction): Option[ClosingTx] = publishedClosingTxs.find(_.tx.txid == tx.txid).orElse(proposedClosingTxs.flatMap(_.all).find(_.tx.txid == tx.txid)) +} final case class DATA_CLOSING(commitments: Commitments, waitingSince: BlockHeight, // how long since we initiated the closing finalScriptPubKey: ByteVector, // where to send all on-chain funds diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala index 9155c841fa..9679f404dc 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala @@ -115,6 +115,8 @@ case class FeerateTooDifferent (override val channelId: Byte case class InvalidAnnouncementSignatures (override val channelId: ByteVector32, annSigs: AnnouncementSignatures) extends ChannelException(channelId, s"invalid announcement signatures: $annSigs") case class InvalidCommitmentSignature (override val channelId: ByteVector32, fundingTxId: TxId, fundingTxIndex: Long, unsignedCommitTx: Transaction) extends ChannelException(channelId, s"invalid commitment signature: fundingTxId=$fundingTxId fundingTxIndex=$fundingTxIndex commitTxId=${unsignedCommitTx.txid} commitTx=$unsignedCommitTx") case class InvalidHtlcSignature (override val channelId: ByteVector32, txId: TxId) extends ChannelException(channelId, s"invalid htlc signature: txId=$txId") +case class CannotGenerateClosingTx (override val channelId: ByteVector32) extends ChannelException(channelId, "failed to generate closing transaction: all outputs are trimmed") +case class MissingCloseSignature (override val channelId: ByteVector32) extends ChannelException(channelId, "closing_complete is missing a signature for a closing transaction including our output") case class InvalidCloseSignature (override val channelId: ByteVector32, txId: TxId) extends ChannelException(channelId, s"invalid close signature: txId=$txId") case class InvalidCloseAmountBelowDust (override val channelId: ByteVector32, txId: TxId) extends ChannelException(channelId, s"invalid closing tx: some outputs are below dust: txId=$txId") case class CommitSigCountMismatch (override val channelId: ByteVector32, expected: Int, actual: Int) extends ChannelException(channelId, s"commit sig count mismatch: expected=$expected actual=$actual") 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 6ced5d241b..e3bf832196 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 @@ -59,6 +59,7 @@ object Helpers { case d: DATA_NORMAL => d.modify(_.commitments.params).using(_.updateFeatures(localInit, remoteInit)) case d: DATA_SHUTDOWN => d.modify(_.commitments.params).using(_.updateFeatures(localInit, remoteInit)) case d: DATA_NEGOTIATING => d.modify(_.commitments.params).using(_.updateFeatures(localInit, remoteInit)) + case d: DATA_NEGOTIATING_SIMPLE => d.modify(_.commitments.params).using(_.updateFeatures(localInit, remoteInit)) case d: DATA_CLOSING => d.modify(_.commitments.params).using(_.updateFeatures(localInit, remoteInit)) case d: DATA_WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT => d.modify(_.commitments.params).using(_.updateFeatures(localInit, remoteInit)) } @@ -709,6 +710,95 @@ object Helpers { } } + /** We are the closer: we sign closing transactions for which we pay the fees. */ + def makeSimpleClosingTx(currentBlockHeight: BlockHeight, keyManager: ChannelKeyManager, commitment: FullCommitment, localScriptPubkey: ByteVector, remoteScriptPubkey: ByteVector, feerate: FeeratePerKw): Either[ChannelException, (ClosingTxs, ClosingComplete)] = { + require(isValidFinalScriptPubkey(localScriptPubkey, allowAnySegwit = true, allowOpReturn = true), "invalid localScriptPubkey") + require(isValidFinalScriptPubkey(remoteScriptPubkey, allowAnySegwit = true, allowOpReturn = true), "invalid remoteScriptPubkey") + // We must convert the feerate to a fee: we must build dummy transactions to compute their weight. + val closingFee = { + val dummyClosingTxs = Transactions.makeSimpleClosingTxs(commitment.commitInput, commitment.localCommit.spec, SimpleClosingTxFee.PaidByUs(0 sat), currentBlockHeight.toLong, localScriptPubkey, remoteScriptPubkey) + dummyClosingTxs.preferred_opt match { + case Some(dummyTx) => + val dummySignedTx = Transactions.addSigs(dummyTx, Transactions.PlaceHolderPubKey, Transactions.PlaceHolderPubKey, Transactions.PlaceHolderSig, Transactions.PlaceHolderSig) + SimpleClosingTxFee.PaidByUs(Transactions.weight2fee(feerate, dummySignedTx.tx.weight())) + case None => return Left(CannotGenerateClosingTx(commitment.channelId)) + } + } + // Now that we know the fee we're ready to pay, we can create our closing transactions. + val closingTxs = Transactions.makeSimpleClosingTxs(commitment.commitInput, commitment.localCommit.spec, closingFee, currentBlockHeight.toLong, localScriptPubkey, remoteScriptPubkey) + // The actual fee we're paying will be bigger than the one we previously computed if we omit our output. + val actualFee = closingTxs.preferred_opt match { + case Some(closingTx) if closingTx.fee > 0.sat => closingTx.fee + case _ => return Left(CannotGenerateClosingTx(commitment.channelId)) + } + val localFundingPubKey = keyManager.fundingPublicKey(commitment.localParams.fundingKeyPath, commitment.fundingTxIndex) + val closingComplete = ClosingComplete(commitment.channelId, actualFee, currentBlockHeight.toLong, TlvStream(Set( + closingTxs.localAndRemote_opt.map(tx => ClosingTlv.CloserAndClosee(keyManager.sign(tx, localFundingPubKey, TxOwner.Local, commitment.params.commitmentFormat))), + closingTxs.localOnly_opt.map(tx => ClosingTlv.CloserNoClosee(keyManager.sign(tx, localFundingPubKey, TxOwner.Local, commitment.params.commitmentFormat))), + closingTxs.remoteOnly_opt.map(tx => ClosingTlv.NoCloserClosee(keyManager.sign(tx, localFundingPubKey, TxOwner.Local, commitment.params.commitmentFormat))), + ).flatten[ClosingTlv])) + Right(closingTxs, closingComplete) + } + + /** + * We are the closee: we choose one of the closer's transactions and sign it back. + * Callers should ignore failures: since the protocol is fully asynchronous, failures here simply mean that the + * closing_complete doesn't match the latest state of the closing negotiation (someone changed their script). + */ + def signSimpleClosingTx(keyManager: ChannelKeyManager, commitment: FullCommitment, localScriptPubkey: ByteVector, remoteScriptPubkey: ByteVector, closingComplete: ClosingComplete): Either[ChannelException, (ClosingTx, ClosingSig)] = { + val closingFee = SimpleClosingTxFee.PaidByThem(closingComplete.fees) + val closingTxs = Transactions.makeSimpleClosingTxs(commitment.commitInput, commitment.localCommit.spec, closingFee, closingComplete.lockTime, localScriptPubkey, remoteScriptPubkey) + // If our output isn't dust, they must provide a signature for a transaction that includes it. + // Note that we're the closee, so we look for signatures including the closee output. + (closingTxs.localAndRemote_opt, closingTxs.localOnly_opt) match { + case (Some(_), Some(_)) if closingComplete.closerAndCloseeSig_opt.isEmpty && closingComplete.noCloserCloseeSig_opt.isEmpty => return Left(MissingCloseSignature(commitment.channelId)) + case (Some(_), None) if closingComplete.closerAndCloseeSig_opt.isEmpty => return Left(MissingCloseSignature(commitment.channelId)) + case (None, Some(_)) if closingComplete.noCloserCloseeSig_opt.isEmpty => return Left(MissingCloseSignature(commitment.channelId)) + case _ => () + } + // We choose the closing signature that matches our preferred closing transaction. + val closingTxsWithSigs = Seq( + closingComplete.closerAndCloseeSig_opt.flatMap(remoteSig => closingTxs.localAndRemote_opt.map(tx => (tx, remoteSig, localSig => ClosingTlv.CloserAndClosee(localSig)))), + closingComplete.noCloserCloseeSig_opt.flatMap(remoteSig => closingTxs.localOnly_opt.map(tx => (tx, remoteSig, localSig => ClosingTlv.NoCloserClosee(localSig)))), + closingComplete.closerNoCloseeSig_opt.flatMap(remoteSig => closingTxs.remoteOnly_opt.map(tx => (tx, remoteSig, localSig => ClosingTlv.CloserNoClosee(localSig)))), + ).flatten + closingTxsWithSigs.headOption match { + case Some((closingTx, remoteSig, sigToTlv)) => + val localFundingPubKey = keyManager.fundingPublicKey(commitment.localParams.fundingKeyPath, commitment.fundingTxIndex) + val localSig = keyManager.sign(closingTx, localFundingPubKey, TxOwner.Local, commitment.params.commitmentFormat) + val signedClosingTx = Transactions.addSigs(closingTx, localFundingPubKey.publicKey, commitment.remoteFundingPubKey, localSig, remoteSig) + Transactions.checkSpendable(signedClosingTx) match { + case Failure(_) => Left(InvalidCloseSignature(commitment.channelId, signedClosingTx.tx.txid)) + case Success(_) => Right(signedClosingTx, ClosingSig(commitment.channelId, TlvStream(sigToTlv(localSig)))) + } + case None => Left(MissingCloseSignature(commitment.channelId)) + } + } + + /** + * We are the closer: they sent us their signature so we should now have a fully signed closing transaction. + * Callers should ignore failures: since the protocol is fully asynchronous, failures here simply mean that the + * closing_sig doesn't match the latest state of the closing negotiation (someone changed their script). + */ + def receiveSimpleClosingSig(keyManager: ChannelKeyManager, commitment: FullCommitment, closingTxs: ClosingTxs, closingSig: ClosingSig): Either[ChannelException, ClosingTx] = { + val closingTxsWithSig = Seq( + closingSig.closerAndCloseeSig_opt.flatMap(sig => closingTxs.localAndRemote_opt.map(tx => (tx, sig))), + closingSig.closerNoCloseeSig_opt.flatMap(sig => closingTxs.localOnly_opt.map(tx => (tx, sig))), + closingSig.noCloserCloseeSig_opt.flatMap(sig => closingTxs.remoteOnly_opt.map(tx => (tx, sig))), + ).flatten + closingTxsWithSig.headOption match { + case Some((closingTx, remoteSig)) => + val localFundingPubKey = keyManager.fundingPublicKey(commitment.localParams.fundingKeyPath, commitment.fundingTxIndex) + val localSig = keyManager.sign(closingTx, localFundingPubKey, TxOwner.Local, commitment.params.commitmentFormat) + val signedClosingTx = Transactions.addSigs(closingTx, localFundingPubKey.publicKey, commitment.remoteFundingPubKey, localSig, remoteSig) + Transactions.checkSpendable(signedClosingTx) match { + case Failure(_) => Left(InvalidCloseSignature(commitment.channelId, signedClosingTx.tx.txid)) + case Success(_) => Right(signedClosingTx) + } + case None => Left(MissingCloseSignature(commitment.channelId)) + } + } + /** * Check that all closing outputs are above bitcoin's dust limit for their script type, otherwise there is a risk * that the closing transaction will not be relayed to miners' mempool and will not confirm. 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 9ece9c06a8..c325cad575 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 @@ -734,10 +734,20 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with } // are there pending signed changes on either side? we need to have received their last revocation! if (d.commitments.hasNoPendingHtlcsOrFeeUpdate) { - // there are no pending signed changes, let's go directly to NEGOTIATING - if (d.commitments.params.localParams.paysClosingFees) { + // there are no pending signed changes, let's directly negotiate a closing transaction + if (Features.canUseFeature(d.commitments.params.localParams.initFeatures, d.commitments.params.remoteParams.initFeatures, Features.SimpleClose)) { + val closingFeerate = d.closingFeerates.map(_.preferred).getOrElse(nodeParams.onChainFeeConf.getClosingFeerate(nodeParams.currentBitcoinCoreFeerates)) + MutualClose.makeSimpleClosingTx(nodeParams.currentBlockHeight, keyManager, d.commitments.latest, localShutdown.scriptPubKey, remoteShutdownScript, closingFeerate) match { + case Left(f) => + log.warning("cannot create local closing txs, waiting for remote closing_complete: {}", f.getMessage) + goto(NEGOTIATING_SIMPLE) using DATA_NEGOTIATING_SIMPLE(d.commitments, localShutdown, remoteShutdown, Nil, Nil) storing() sending sendList + case Right((closingTxs, closingComplete)) => + log.debug("signing local mutual close transactions: {}", closingTxs) + goto(NEGOTIATING_SIMPLE) using DATA_NEGOTIATING_SIMPLE(d.commitments, localShutdown, remoteShutdown, closingTxs :: Nil, Nil) storing() sending sendList :+ closingComplete + } + } else if (d.commitments.params.localParams.paysClosingFees) { // we pay the closing fees, so we initiate the negotiation by sending the first closing_signed - val (closingTx, closingSigned) = Closing.MutualClose.makeFirstClosingTx(keyManager, d.commitments.latest, localShutdown.scriptPubKey, remoteShutdownScript, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf, d.closingFeerates) + val (closingTx, closingSigned) = MutualClose.makeFirstClosingTx(keyManager, d.commitments.latest, localShutdown.scriptPubKey, remoteShutdownScript, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf, d.closingFeerates) goto(NEGOTIATING) using DATA_NEGOTIATING(d.commitments, localShutdown, remoteShutdown, List(List(ClosingTxProposed(closingTx, closingSigned))), bestUnpublishedClosingTx_opt = None) storing() sending sendList :+ closingSigned } else { // we are not the channel initiator, will wait for their closing_signed @@ -1513,9 +1523,19 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with log.debug("received a new sig:\n{}", commitments1.latest.specs2String) context.system.eventStream.publish(ChannelSignatureReceived(self, commitments1)) if (commitments1.hasNoPendingHtlcsOrFeeUpdate) { - if (d.commitments.params.localParams.paysClosingFees) { + if (Features.canUseFeature(d.commitments.params.localParams.initFeatures, d.commitments.params.remoteParams.initFeatures, Features.SimpleClose)) { + val closingFeerate = d.closingFeerates.map(_.preferred).getOrElse(nodeParams.onChainFeeConf.getClosingFeerate(nodeParams.currentBitcoinCoreFeerates)) + MutualClose.makeSimpleClosingTx(nodeParams.currentBlockHeight, keyManager, d.commitments.latest, localShutdown.scriptPubKey, remoteShutdown.scriptPubKey, closingFeerate) match { + case Left(f) => + log.warning("cannot create local closing txs, waiting for remote closing_complete: {}", f.getMessage) + goto(NEGOTIATING_SIMPLE) using DATA_NEGOTIATING_SIMPLE(d.commitments, localShutdown, remoteShutdown, Nil, Nil) storing() sending revocation + case Right((closingTxs, closingComplete)) => + log.debug("signing local mutual close transactions: {}", closingTxs) + goto(NEGOTIATING_SIMPLE) using DATA_NEGOTIATING_SIMPLE(d.commitments, localShutdown, remoteShutdown, closingTxs :: Nil, Nil) storing() sending revocation :: closingComplete :: Nil + } + } else if (d.commitments.params.localParams.paysClosingFees) { // we pay the closing fees, so we initiate the negotiation by sending the first closing_signed - val (closingTx, closingSigned) = Closing.MutualClose.makeFirstClosingTx(keyManager, commitments1.latest, localShutdown.scriptPubKey, remoteShutdown.scriptPubKey, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf, closingFeerates) + val (closingTx, closingSigned) = MutualClose.makeFirstClosingTx(keyManager, commitments1.latest, localShutdown.scriptPubKey, remoteShutdown.scriptPubKey, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf, closingFeerates) goto(NEGOTIATING) using DATA_NEGOTIATING(commitments1, localShutdown, remoteShutdown, List(List(ClosingTxProposed(closingTx, closingSigned))), bestUnpublishedClosingTx_opt = None) storing() sending revocation :: closingSigned :: Nil } else { // we are not the channel initiator, will wait for their closing_signed @@ -1555,9 +1575,19 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with } if (commitments1.hasNoPendingHtlcsOrFeeUpdate) { log.debug("switching to NEGOTIATING spec:\n{}", commitments1.latest.specs2String) - if (d.commitments.params.localParams.paysClosingFees) { + if (Features.canUseFeature(d.commitments.params.localParams.initFeatures, d.commitments.params.remoteParams.initFeatures, Features.SimpleClose)) { + val closingFeerate = d.closingFeerates.map(_.preferred).getOrElse(nodeParams.onChainFeeConf.getClosingFeerate(nodeParams.currentBitcoinCoreFeerates)) + MutualClose.makeSimpleClosingTx(nodeParams.currentBlockHeight, keyManager, d.commitments.latest, localShutdown.scriptPubKey, remoteShutdown.scriptPubKey, closingFeerate) match { + case Left(f) => + log.warning("cannot create local closing txs, waiting for remote closing_complete: {}", f.getMessage) + goto(NEGOTIATING_SIMPLE) using DATA_NEGOTIATING_SIMPLE(d.commitments, localShutdown, remoteShutdown, Nil, Nil) storing() + case Right((closingTxs, closingComplete)) => + log.debug("signing local mutual close transactions: {}", closingTxs) + goto(NEGOTIATING_SIMPLE) using DATA_NEGOTIATING_SIMPLE(d.commitments, localShutdown, remoteShutdown, closingTxs :: Nil, Nil) storing() sending closingComplete + } + } else if (d.commitments.params.localParams.paysClosingFees) { // we pay the closing fees, so we initiate the negotiation by sending the first closing_signed - val (closingTx, closingSigned) = Closing.MutualClose.makeFirstClosingTx(keyManager, commitments1.latest, localShutdown.scriptPubKey, remoteShutdown.scriptPubKey, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf, closingFeerates) + val (closingTx, closingSigned) = MutualClose.makeFirstClosingTx(keyManager, commitments1.latest, localShutdown.scriptPubKey, remoteShutdown.scriptPubKey, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf, closingFeerates) goto(NEGOTIATING) using DATA_NEGOTIATING(commitments1, localShutdown, remoteShutdown, List(List(ClosingTxProposed(closingTx, closingSigned))), bestUnpublishedClosingTx_opt = None) storing() sending closingSigned } else { // we are not the channel initiator, will wait for their closing_signed @@ -1572,6 +1602,15 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with case Left(cause) => handleLocalError(cause, d, Some(revocation)) } + case Event(shutdown: Shutdown, d: DATA_SHUTDOWN) => + if (shutdown.scriptPubKey != d.remoteShutdown.scriptPubKey) { + log.debug("our peer updated their shutdown script (previous={}, current={})", d.remoteShutdown.scriptPubKey, shutdown.scriptPubKey) + stay() using d.copy(remoteShutdown = shutdown) storing() + } else { + // This is a retransmission of their previous shutdown, we can ignore it. + stay() + } + case Event(r: RevocationTimeout, d: DATA_SHUTDOWN) => handleRevocationTimeout(r, d) case Event(ProcessCurrentBlockHeight(c), d: DATA_SHUTDOWN) => handleNewBlock(c, d) @@ -1579,17 +1618,18 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with case Event(c: CurrentFeerates.BitcoinCore, d: DATA_SHUTDOWN) => handleCurrentFeerate(c, d) case Event(c: CMD_CLOSE, d: DATA_SHUTDOWN) => - c.feerates match { - case Some(feerates) if c.feerates != d.closingFeerates => - if (c.scriptPubKey.nonEmpty && !c.scriptPubKey.contains(d.localShutdown.scriptPubKey)) { - log.warning("cannot update closing script when closing is already in progress") - handleCommandError(ClosingAlreadyInProgress(d.channelId), c) - } else { - log.info("updating our closing feerates: {}", feerates) - handleCommandSuccess(c, d.copy(closingFeerates = c.feerates)) storing() - } - case _ => - handleCommandError(ClosingAlreadyInProgress(d.channelId), c) + val useSimpleClose = Features.canUseFeature(d.commitments.params.localParams.initFeatures, d.commitments.params.remoteParams.initFeatures, Features.SimpleClose) + val localShutdown_opt = c.scriptPubKey match { + case Some(scriptPubKey) if scriptPubKey != d.localShutdown.scriptPubKey && useSimpleClose => Some(Shutdown(d.channelId, scriptPubKey)) + case _ => None + } + if (c.scriptPubKey.exists(_ != d.localShutdown.scriptPubKey) && !useSimpleClose) { + handleCommandError(ClosingAlreadyInProgress(d.channelId), c) + } else if (localShutdown_opt.nonEmpty || c.feerates.nonEmpty) { + val d1 = d.copy(localShutdown = localShutdown_opt.getOrElse(d.localShutdown), closingFeerates = c.feerates.orElse(d.closingFeerates)) + handleCommandSuccess(c, d1) storing() sending localShutdown_opt.toSeq + } else { + handleCommandError(ClosingAlreadyInProgress(d.channelId), c) } case Event(e: Error, d: DATA_SHUTDOWN) => handleRemoteError(e, d) @@ -1597,17 +1637,17 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with }) when(NEGOTIATING)(handleExceptions { - // Upon reconnection, nodes must re-transmit their shutdown message, so we may receive it now. case Event(remoteShutdown: Shutdown, d: DATA_NEGOTIATING) => if (remoteShutdown != d.remoteShutdown) { - // This is a spec violation: it will likely lead to a disagreement when exchanging closing_signed and a force-close. - log.warning("received unexpected shutdown={} (previous={})", remoteShutdown, d.remoteShutdown) + // This may lead to a signature mismatch if our peer changed their script without using option_simple_close. + stay() using d.copy(remoteShutdown = remoteShutdown) storing() + } else { + stay() } - stay() case Event(c: ClosingSigned, d: DATA_NEGOTIATING) => val (remoteClosingFee, remoteSig) = (c.feeSatoshis, c.signature) - Closing.MutualClose.checkClosingSignature(keyManager, d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, remoteClosingFee, remoteSig) match { + MutualClose.checkClosingSignature(keyManager, d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, remoteClosingFee, remoteSig) match { case Right((signedClosingTx, closingSignedRemoteFees)) => val lastLocalClosingSigned_opt = d.closingTxProposed.last.lastOption if (lastLocalClosingSigned_opt.exists(_.localClosingSigned.feeSatoshis == remoteClosingFee)) { @@ -1630,7 +1670,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with case Some(ClosingSignedTlv.FeeRange(minFee, maxFee)) if !d.commitments.params.localParams.paysClosingFees => // if we are not paying the closing fees and they proposed a fee range, we pick a value in that range and they should accept it without further negotiation // we don't care much about the closing fee since they're paying it (not us) and we can use CPFP if we want to speed up confirmation - val localClosingFees = Closing.MutualClose.firstClosingFee(d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf) + val localClosingFees = MutualClose.firstClosingFee(d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf) if (maxFee < localClosingFees.min) { log.warning("their highest closing fee is below our minimum fee: {} < {}", maxFee, localClosingFees.min) stay() sending Warning(d.channelId, s"closing fee range must not be below ${localClosingFees.min}") @@ -1645,7 +1685,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with log.info("accepting their closing fee={}", remoteClosingFee) handleMutualClose(signedClosingTx, Left(d.copy(bestUnpublishedClosingTx_opt = Some(signedClosingTx)))) sending closingSignedRemoteFees } else { - val (closingTx, closingSigned) = Closing.MutualClose.makeClosingTx(keyManager, d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, ClosingFees(closingFee, minFee, maxFee)) + val (closingTx, closingSigned) = MutualClose.makeClosingTx(keyManager, d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, ClosingFees(closingFee, minFee, maxFee)) log.info("proposing closing fee={} in their fee range (min={} max={})", closingSigned.feeSatoshis, minFee, maxFee) val closingTxProposed1 = (d.closingTxProposed: @unchecked) match { case previousNegotiations :+ currentNegotiation => previousNegotiations :+ (currentNegotiation :+ ClosingTxProposed(closingTx, closingSigned)) @@ -1657,9 +1697,9 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with val lastLocalClosingFee_opt = lastLocalClosingSigned_opt.map(_.localClosingSigned.feeSatoshis) val (closingTx, closingSigned) = { // if we are not the channel initiator and we were waiting for them to send their first closing_signed, we don't have a lastLocalClosingFee, so we compute a firstClosingFee - val localClosingFees = Closing.MutualClose.firstClosingFee(d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf) - val nextPreferredFee = Closing.MutualClose.nextClosingFee(lastLocalClosingFee_opt.getOrElse(localClosingFees.preferred), remoteClosingFee) - Closing.MutualClose.makeClosingTx(keyManager, d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, localClosingFees.copy(preferred = nextPreferredFee)) + val localClosingFees = MutualClose.firstClosingFee(d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf) + val nextPreferredFee = MutualClose.nextClosingFee(lastLocalClosingFee_opt.getOrElse(localClosingFees.preferred), remoteClosingFee) + MutualClose.makeClosingTx(keyManager, d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, localClosingFees.copy(preferred = nextPreferredFee)) } val closingTxProposed1 = (d.closingTxProposed: @unchecked) match { case previousNegotiations :+ currentNegotiation => previousNegotiations :+ (currentNegotiation :+ ClosingTxProposed(closingTx, closingSigned)) @@ -1688,7 +1728,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with handleCommandError(ClosingAlreadyInProgress(d.channelId), c) } else { log.info("updating our closing feerates: {}", feerates) - val (closingTx, closingSigned) = Closing.MutualClose.makeFirstClosingTx(keyManager, d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf, Some(feerates)) + val (closingTx, closingSigned) = MutualClose.makeFirstClosingTx(keyManager, d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf, Some(feerates)) val closingTxProposed1 = d.closingTxProposed match { case previousNegotiations :+ currentNegotiation => previousNegotiations :+ (currentNegotiation :+ ClosingTxProposed(closingTx, closingSigned)) case previousNegotiations => previousNegotiations :+ List(ClosingTxProposed(closingTx, closingSigned)) @@ -1699,10 +1739,96 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with handleCommandError(ClosingAlreadyInProgress(d.channelId), c) } + case Event(WatchFundingSpentTriggered(tx), d: DATA_NEGOTIATING) if d.closingTxProposed.flatten.exists(_.unsignedTx.tx.txid == tx.txid) => + // they can publish a closing tx with any sig we sent them, even if we are not done negotiating + handleMutualClose(getMutualClosePublished(tx, d.closingTxProposed), Left(d)) + + case Event(WatchFundingSpentTriggered(tx), d: DATA_NEGOTIATING) if d.bestUnpublishedClosingTx_opt.exists(_.tx.txid == tx.txid) => + log.warning(s"looks like a mutual close tx has been published from the outside of the channel: closingTxId=${tx.txid}") + // if we were in the process of closing and already received a closing sig from the counterparty, it's always better to use that + handleMutualClose(d.bestUnpublishedClosingTx_opt.get, Left(d)) + case Event(e: Error, d: DATA_NEGOTIATING) => handleRemoteError(e, d) }) + when(NEGOTIATING_SIMPLE)(handleExceptions { + case Event(remoteShutdown: Shutdown, d: DATA_NEGOTIATING_SIMPLE) => + // Our peer wants to create a new version of their closing transaction. + // We don't need to update our version of the closing transaction: we simply wait for their closing_complete. + stay() using d.copy(remoteShutdown = remoteShutdown) storing() + + case Event(closingComplete: ClosingComplete, d: DATA_NEGOTIATING_SIMPLE) => + MutualClose.signSimpleClosingTx(keyManager, d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, closingComplete) match { + case Left(f) => + // This may happen if scripts were updated concurrently, so we simply ignore failures. + // Bolt 2: + // - If the signature field is not valid for the corresponding closing transaction: + // - MUST ignore `closing_complete`. + log.warning("invalid closing_complete: {}", f.getMessage) + stay() + case Right((signedClosingTx, closingSig)) => + log.debug("signing remote mutual close transaction: {}", signedClosingTx.tx) + val d1 = d.copy(publishedClosingTxs = d.publishedClosingTxs :+ signedClosingTx) + stay() using d1 storing() calling doPublish(signedClosingTx, localPaysClosingFees = false) sending closingSig + } + + case Event(closingSig: ClosingSig, d: DATA_NEGOTIATING_SIMPLE) => + MutualClose.receiveSimpleClosingSig(keyManager, d.commitments.latest, d.proposedClosingTxs.last, closingSig) match { + case Left(f) => + // This may happen if scripts were updated concurrently, so we simply ignore failures. + // Bolt 2: + // - If the signature field is not valid for the corresponding closing transaction: + // - MUST ignore `closing_sig`. + log.warning("invalid closing_sig: {}", f.getMessage) + stay() + case Right(signedClosingTx) => + log.debug("received signatures for local mutual close transaction: {}", signedClosingTx.tx) + val d1 = d.copy(publishedClosingTxs = d.publishedClosingTxs :+ signedClosingTx) + stay() using d1 storing() calling doPublish(signedClosingTx, localPaysClosingFees = true) + } + + case Event(WatchFundingSpentTriggered(tx), d: DATA_NEGOTIATING_SIMPLE) if d.findClosingTx(tx).nonEmpty => + if (!d.publishedClosingTxs.exists(_.tx.txid == tx.txid)) { + // They published one of our closing transactions without sending us their signature. + // We need to publish it ourselves to record the fees and watch for confirmation. + val closingTx = d.findClosingTx(tx).get.copy(tx = tx) + stay() using d.copy(publishedClosingTxs = d.publishedClosingTxs :+ closingTx) storing() calling doPublish(closingTx, localPaysClosingFees = true) + } else { + // This is one of the transactions we published. + stay() + } + + case Event(WatchTxConfirmedTriggered(_, _, tx), d: DATA_NEGOTIATING_SIMPLE) if d.findClosingTx(tx).nonEmpty => + val closingType = MutualClose(d.findClosingTx(tx).get) + log.info("channel closed (type={})", EventType.Closed(closingType).label) + context.system.eventStream.publish(ChannelClosed(self, d.channelId, closingType, d.commitments)) + goto(CLOSED) using d storing() + + case Event(c: CMD_CLOSE, d: DATA_NEGOTIATING_SIMPLE) => + val localShutdown_opt = c.scriptPubKey match { + case Some(scriptPubKey) if scriptPubKey != d.localShutdown.scriptPubKey => Some(Shutdown(d.channelId, scriptPubKey)) + case _ => None + } + if (localShutdown_opt.nonEmpty || c.feerates.nonEmpty) { + val localScript = localShutdown_opt.map(_.scriptPubKey).getOrElse(d.localShutdown.scriptPubKey) + val feerate = c.feerates.map(_.preferred).getOrElse(nodeParams.onChainFeeConf.getClosingFeerate(nodeParams.currentBitcoinCoreFeerates)) + MutualClose.makeSimpleClosingTx(nodeParams.currentBlockHeight, keyManager, d.commitments.latest, localScript, d.remoteShutdown.scriptPubKey, feerate) match { + case Left(f) => handleCommandError(f, c) + case Right((closingTxs, closingComplete)) => + log.info("new closing transaction created with script={} fees={}", localScript, closingComplete.fees) + log.debug("signing local mutual close transactions: {}", closingTxs) + val d1 = d.copy(localShutdown = localShutdown_opt.getOrElse(d.localShutdown), proposedClosingTxs = d.proposedClosingTxs :+ closingTxs) + stay() using d1 storing() sending localShutdown_opt.toSeq :+ closingComplete + } + } else { + handleCommandError(ClosingAlreadyInProgress(d.channelId), c) + } + + case Event(e: Error, d: DATA_NEGOTIATING_SIMPLE) => handleRemoteError(e, d) + + }) + when(CLOSING)(handleExceptions { case Event(c: HtlcSettlementCommand, d: DATA_CLOSING) => (c match { @@ -2352,6 +2478,15 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with goto(NEGOTIATING) using d.copy(closingTxProposed = closingTxProposed1) sending d.localShutdown } + case Event(_: ChannelReestablish, d: DATA_NEGOTIATING_SIMPLE) => + // We retransmit our shutdown: we may have updated our script and they may not have received it. + // We also sign a new round of closing transactions since network fees may have changed while we were offline. + val closingFeerate = nodeParams.onChainFeeConf.getClosingFeerate(nodeParams.currentBitcoinCoreFeerates) + Closing.MutualClose.makeSimpleClosingTx(nodeParams.currentBlockHeight, keyManager, d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, closingFeerate) match { + case Left(_) => goto(NEGOTIATING_SIMPLE) using d sending d.localShutdown + case Right((closingTxs, closingComplete)) => goto(NEGOTIATING_SIMPLE) using d.copy(proposedClosingTxs = d.proposedClosingTxs :+ closingTxs) sending Seq(d.localShutdown, closingComplete) + } + // This handler is a workaround for an issue in lnd: starting with versions 0.10 / 0.11, they sometimes fail to send // a channel_reestablish when reconnecting a channel that recently got confirmed, and instead send a channel_ready // first and then go silent. This is due to a race condition on their side, so we trigger a reconnection, hoping that @@ -2507,6 +2642,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with case d: DATA_NORMAL => d.copy(commitments = commitments1) case d: DATA_SHUTDOWN => d.copy(commitments = commitments1) case d: DATA_NEGOTIATING => d.copy(commitments = commitments1) + case d: DATA_NEGOTIATING_SIMPLE => d.copy(commitments = commitments1) case d: DATA_WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT => d.copy(commitments = commitments1) case d: DATA_CLOSING => d.copy(commitments = commitments1) } @@ -2534,6 +2670,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with case d: DATA_NORMAL => d.copy(commitments = commitments1) case d: DATA_SHUTDOWN => d.copy(commitments = commitments1) case d: DATA_NEGOTIATING => d.copy(commitments = commitments1) + case d: DATA_NEGOTIATING_SIMPLE => d.copy(commitments = commitments1) case d: DATA_WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT => d.copy(commitments = commitments1) case d: DATA_CLOSING => d // there is a dedicated handler in CLOSING state } @@ -2541,15 +2678,6 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with case Left(_) => stay() } - case Event(WatchFundingSpentTriggered(tx), d: DATA_NEGOTIATING) if d.closingTxProposed.flatten.exists(_.unsignedTx.tx.txid == tx.txid) => - // they can publish a closing tx with any sig we sent them, even if we are not done negotiating - handleMutualClose(getMutualClosePublished(tx, d.closingTxProposed), Left(d)) - - case Event(WatchFundingSpentTriggered(tx), d: DATA_NEGOTIATING) if d.bestUnpublishedClosingTx_opt.exists(_.tx.txid == tx.txid) => - log.warning(s"looks like a mutual close tx has been published from the outside of the channel: closingTxId=${tx.txid}") - // if we were in the process of closing and already received a closing sig from the counterparty, it's always better to use that - handleMutualClose(d.bestUnpublishedClosingTx_opt.get, Left(d)) - case Event(WatchFundingSpentTriggered(tx), d: ChannelDataWithCommitments) => if (d.commitments.all.map(_.fundingTxId).contains(tx.txid)) { // if the spending tx is itself a funding tx, this is a splice and there is nothing to do @@ -2623,7 +2751,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with case (SYNCING, NORMAL, d1: DATA_NORMAL, d2: DATA_NORMAL) if d1.channelUpdate != d2.channelUpdate || d1.channelAnnouncement != d2.channelAnnouncement => Some(EmitLocalChannelUpdate("syncing->normal", d2, sendToPeer = d2.channelAnnouncement.isEmpty)) case (NORMAL, OFFLINE, d1: DATA_NORMAL, d2: DATA_NORMAL) if d1.channelUpdate != d2.channelUpdate || d1.channelAnnouncement != d2.channelAnnouncement => Some(EmitLocalChannelUpdate("normal->offline", d2, sendToPeer = false)) case (OFFLINE, OFFLINE, d1: DATA_NORMAL, d2: DATA_NORMAL) if d1.channelUpdate != d2.channelUpdate || d1.channelAnnouncement != d2.channelAnnouncement => Some(EmitLocalChannelUpdate("offline->offline", d2, sendToPeer = false)) - case (NORMAL | SYNCING | OFFLINE, SHUTDOWN | NEGOTIATING | CLOSING | CLOSED | ERR_INFORMATION_LEAK | WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT, d: DATA_NORMAL, _) => Some(EmitLocalChannelDown(d)) + case (NORMAL | SYNCING | OFFLINE, SHUTDOWN | NEGOTIATING | NEGOTIATING_SIMPLE | CLOSING | CLOSED | ERR_INFORMATION_LEAK | WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT, d: DATA_NORMAL, _) => Some(EmitLocalChannelDown(d)) case _ => None } emitEvent_opt.foreach { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonHandlers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonHandlers.scala index a5efa07cb6..a8458f4ec5 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonHandlers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/CommonHandlers.scala @@ -106,6 +106,7 @@ trait CommonHandlers { case d: DATA_NORMAL if d.localShutdown.isDefined => d.localShutdown.get.scriptPubKey case d: DATA_SHUTDOWN => d.localShutdown.scriptPubKey case d: DATA_NEGOTIATING => d.localShutdown.scriptPubKey + case d: DATA_NEGOTIATING_SIMPLE => d.localShutdown.scriptPubKey case d: DATA_CLOSING => d.finalScriptPubKey case d => d.commitments.params.localParams.upfrontShutdownScript_opt match { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ErrorHandlers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ErrorHandlers.scala index 4ca0696caa..6fd1de0635 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ErrorHandlers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ErrorHandlers.scala @@ -87,6 +87,10 @@ trait ErrorHandlers extends CommonHandlers { log.info(s"we have a valid closing tx, publishing it instead of our commitment: closingTxId=${bestUnpublishedClosingTx.tx.txid}") // if we were in the process of closing and already received a closing sig from the counterparty, it's always better to use that handleMutualClose(bestUnpublishedClosingTx, Left(negotiating)) + case negotiating: DATA_NEGOTIATING_SIMPLE if negotiating.publishedClosingTxs.nonEmpty => + // We have published at least one mutual close transaction, it's better to use it instead of our local commit. + val closing = DATA_CLOSING(negotiating.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = negotiating.localShutdown.scriptPubKey, mutualCloseProposed = negotiating.proposedClosingTxs.flatMap(_.all), mutualClosePublished = negotiating.publishedClosingTxs) + goto(CLOSING) using closing storing() case dd: ChannelDataWithCommitments => // We publish our commitment even if we have nothing at stake: it's a nice thing to do because it lets our peer // get their funds back without delays. @@ -133,6 +137,10 @@ trait ErrorHandlers extends CommonHandlers { case negotiating@DATA_NEGOTIATING(_, _, _, _, Some(bestUnpublishedClosingTx)) => // if we were in the process of closing and already received a closing sig from the counterparty, it's always better to use that handleMutualClose(bestUnpublishedClosingTx, Left(negotiating)) + case negotiating: DATA_NEGOTIATING_SIMPLE if negotiating.publishedClosingTxs.nonEmpty => + // We have published at least one mutual close transaction, it's better to use it instead of our local commit. + val closing = DATA_CLOSING(negotiating.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = negotiating.localShutdown.scriptPubKey, mutualCloseProposed = negotiating.proposedClosingTxs.flatMap(_.all), mutualClosePublished = negotiating.publishedClosingTxs) + goto(CLOSING) using closing storing() // NB: we publish the commitment even if we have nothing at stake (in a dataloss situation our peer will send us an error just for that) case hasCommitments: ChannelDataWithCommitments => if (e.toAscii == "internal error") { @@ -210,6 +218,7 @@ trait ErrorHandlers extends CommonHandlers { val nextData = d match { case closing: DATA_CLOSING => closing.copy(localCommitPublished = Some(localCommitPublished)) case negotiating: DATA_NEGOTIATING => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, negotiating.closingTxProposed.flatten.map(_.unsignedTx), localCommitPublished = Some(localCommitPublished)) + case negotiating: DATA_NEGOTIATING_SIMPLE => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, mutualCloseProposed = negotiating.proposedClosingTxs.flatMap(_.all), mutualClosePublished = negotiating.publishedClosingTxs, localCommitPublished = Some(localCommitPublished)) case _ => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, mutualCloseProposed = Nil, localCommitPublished = Some(localCommitPublished)) } goto(CLOSING) using nextData storing() calling doPublish(localCommitPublished, commitment) @@ -256,6 +265,7 @@ trait ErrorHandlers extends CommonHandlers { val nextData = d match { case closing: DATA_CLOSING => closing.copy(remoteCommitPublished = Some(remoteCommitPublished)) case negotiating: DATA_NEGOTIATING => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, mutualCloseProposed = negotiating.closingTxProposed.flatten.map(_.unsignedTx), remoteCommitPublished = Some(remoteCommitPublished)) + case negotiating: DATA_NEGOTIATING_SIMPLE => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, mutualCloseProposed = negotiating.proposedClosingTxs.flatMap(_.all), mutualClosePublished = negotiating.publishedClosingTxs, remoteCommitPublished = Some(remoteCommitPublished)) case _ => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, mutualCloseProposed = Nil, remoteCommitPublished = Some(remoteCommitPublished)) } goto(CLOSING) using nextData storing() calling doPublish(remoteCommitPublished, commitments) @@ -274,6 +284,7 @@ trait ErrorHandlers extends CommonHandlers { val nextData = d match { case closing: DATA_CLOSING => closing.copy(nextRemoteCommitPublished = Some(remoteCommitPublished)) case negotiating: DATA_NEGOTIATING => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, mutualCloseProposed = negotiating.closingTxProposed.flatten.map(_.unsignedTx), nextRemoteCommitPublished = Some(remoteCommitPublished)) + case negotiating: DATA_NEGOTIATING_SIMPLE => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, mutualCloseProposed = negotiating.proposedClosingTxs.flatMap(_.all), mutualClosePublished = negotiating.publishedClosingTxs, remoteCommitPublished = Some(remoteCommitPublished)) // NB: if there is a next commitment, we can't be in DATA_WAIT_FOR_FUNDING_CONFIRMED so we don't have the case where fundingTx is defined case _ => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, mutualCloseProposed = Nil, nextRemoteCommitPublished = Some(remoteCommitPublished)) } @@ -313,6 +324,7 @@ trait ErrorHandlers extends CommonHandlers { val nextData = d match { case closing: DATA_CLOSING => closing.copy(revokedCommitPublished = closing.revokedCommitPublished :+ revokedCommitPublished) case negotiating: DATA_NEGOTIATING => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, mutualCloseProposed = negotiating.closingTxProposed.flatten.map(_.unsignedTx), revokedCommitPublished = revokedCommitPublished :: Nil) + case negotiating: DATA_NEGOTIATING_SIMPLE => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, mutualCloseProposed = negotiating.proposedClosingTxs.flatMap(_.all), mutualClosePublished = negotiating.publishedClosingTxs, revokedCommitPublished = revokedCommitPublished :: Nil) // NB: if there is a revoked commitment, we can't be in DATA_WAIT_FOR_FUNDING_CONFIRMED so we don't have the case where fundingTx is defined case _ => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, mutualCloseProposed = Nil, revokedCommitPublished = revokedCommitPublished :: Nil) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/io/OpenChannelInterceptor.scala b/eclair-core/src/main/scala/fr/acinq/eclair/io/OpenChannelInterceptor.scala index 8714ac9b5a..51321cac8a 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/io/OpenChannelInterceptor.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/io/OpenChannelInterceptor.scala @@ -274,6 +274,7 @@ private class OpenChannelInterceptor(peer: ActorRef[Any], case _: DATA_NORMAL => false case _: DATA_SHUTDOWN => true case _: DATA_NEGOTIATING => true + case _: DATA_NEGOTIATING_SIMPLE => true case _: DATA_CLOSING => true case _: DATA_WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT => true } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/io/PeerReadyNotifier.scala b/eclair-core/src/main/scala/fr/acinq/eclair/io/PeerReadyNotifier.scala index 932e414cdf..ecb92805d3 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/io/PeerReadyNotifier.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/io/PeerReadyNotifier.scala @@ -198,6 +198,7 @@ object PeerReadyNotifier { case channel.NORMAL => true case channel.SHUTDOWN => true case channel.NEGOTIATING => true + case channel.NEGOTIATING_SIMPLE => true case channel.CLOSING => true case channel.CLOSED => true case channel.WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT => true diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/json/JsonSerializers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/json/JsonSerializers.scala index 90e07ca162..9b14f6993b 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/json/JsonSerializers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/json/JsonSerializers.scala @@ -643,6 +643,7 @@ object CustomTypeHints { classOf[DATA_NORMAL], classOf[DATA_SHUTDOWN], classOf[DATA_NEGOTIATING], + classOf[DATA_NEGOTIATING_SIMPLE], classOf[DATA_CLOSING], classOf[DATA_WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT] ), typeHintFieldName = "type") diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala index 55993903a7..c96bb9d5e9 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala @@ -835,6 +835,77 @@ object Transactions { ClosingTx(commitTxInput, tx, toLocalOutput) } + // @formatter:off + /** We always create multiple versions of each closing transaction, where fees are either paid by us or by our peer. */ + sealed trait SimpleClosingTxFee + object SimpleClosingTxFee { + case class PaidByUs(fee: Satoshi) extends SimpleClosingTxFee + case class PaidByThem(fee: Satoshi) extends SimpleClosingTxFee + } + // @formatter:on + + /** Each closing attempt can result in multiple potential closing transactions, depending on which outputs are included. */ + case class ClosingTxs(localAndRemote_opt: Option[ClosingTx], localOnly_opt: Option[ClosingTx], remoteOnly_opt: Option[ClosingTx]) { + /** Preferred closing transaction for this closing attempt. */ + val preferred_opt: Option[ClosingTx] = localAndRemote_opt.orElse(localOnly_opt).orElse(remoteOnly_opt) + val all: Seq[ClosingTx] = Seq(localAndRemote_opt, localOnly_opt, remoteOnly_opt).flatten + + override def toString: String = s"localAndRemote=${localAndRemote_opt.map(_.tx.toString()).getOrElse("n/a")}, localOnly=${localOnly_opt.map(_.tx.toString()).getOrElse("n/a")}, remoteOnly=${remoteOnly_opt.map(_.tx.toString()).getOrElse("n/a")}" + } + + def makeSimpleClosingTxs(input: InputInfo, spec: CommitmentSpec, fee: SimpleClosingTxFee, lockTime: Long, localScriptPubKey: ByteVector, remoteScriptPubKey: ByteVector): ClosingTxs = { + require(spec.htlcs.isEmpty, "there shouldn't be any pending htlcs") + + val txNoOutput = Transaction(2, Seq(TxIn(input.outPoint, ByteVector.empty, sequence = 0xFFFFFFFDL)), Nil, lockTime) + + // We compute the remaining balance for each side after paying the closing fees. + // This lets us decide whether outputs can be included in the closing transaction or not. + val (toLocalAmount, toRemoteAmount) = fee match { + case SimpleClosingTxFee.PaidByUs(fee) => (spec.toLocal.truncateToSatoshi - fee, spec.toRemote.truncateToSatoshi) + case SimpleClosingTxFee.PaidByThem(fee) => (spec.toLocal.truncateToSatoshi, spec.toRemote.truncateToSatoshi - fee) + } + + // An OP_RETURN script may be provided, but only when burning all of the peer's balance to fees. + val toLocalOutput_opt = if (toLocalAmount >= dustLimit(localScriptPubKey)) { + val amount = if (isOpReturn(localScriptPubKey)) 0.sat else toLocalAmount + Some(TxOut(amount, localScriptPubKey)) + } else { + None + } + val toRemoteOutput_opt = if (toRemoteAmount >= dustLimit(remoteScriptPubKey)) { + val amount = if (isOpReturn(remoteScriptPubKey)) 0.sat else toRemoteAmount + Some(TxOut(amount, remoteScriptPubKey)) + } else { + None + } + + // We may create multiple closing transactions based on which outputs may be included. + (toLocalOutput_opt, toRemoteOutput_opt) match { + case (Some(toLocalOutput), Some(toRemoteOutput)) => + val txLocalAndRemote = LexicographicalOrdering.sort(txNoOutput.copy(txOut = Seq(toLocalOutput, toRemoteOutput))) + val toLocalOutputInfo = findPubKeyScriptIndex(txLocalAndRemote, localScriptPubKey).map(index => OutputInfo(index, toLocalOutput.amount, localScriptPubKey)).toOption + ClosingTxs( + localAndRemote_opt = Some(ClosingTx(input, txLocalAndRemote, toLocalOutputInfo)), + // We also provide a version of the transaction without the remote output, which they may want to omit if not economical to spend. + localOnly_opt = Some(ClosingTx(input, txNoOutput.copy(txOut = Seq(toLocalOutput)), Some(OutputInfo(0, toLocalOutput.amount, localScriptPubKey)))), + remoteOnly_opt = None + ) + case (Some(toLocalOutput), None) => + ClosingTxs( + localAndRemote_opt = None, + localOnly_opt = Some(ClosingTx(input, txNoOutput.copy(txOut = Seq(toLocalOutput)), Some(OutputInfo(0, toLocalOutput.amount, localScriptPubKey)))), + remoteOnly_opt = None + ) + case (None, Some(toRemoteOutput)) => + ClosingTxs( + localAndRemote_opt = None, + localOnly_opt = None, + remoteOnly_opt = Some(ClosingTx(input, txNoOutput.copy(txOut = Seq(toRemoteOutput)), None)) + ) + case (None, None) => ClosingTxs(None, None, None) + } + } + def findPubKeyScriptIndex(tx: Transaction, pubkeyScript: ByteVector): Either[TxGenerationSkipped, Int] = { val outputIndex = tx.txOut.indexWhere(_.publicKeyScript == pubkeyScript) if (outputIndex >= 0) { 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 afcfbc4cbd..8dbd2bb56f 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 @@ -754,6 +754,18 @@ private[channel] object ChannelCodecs4 { ("closingTxProposed" | listOfN(uint16, listOfN(uint16, lengthDelimited(closingTxProposedCodec)))) :: ("bestUnpublishedClosingTx_opt" | optional(bool8, closingTxCodec))).as[DATA_NEGOTIATING] + private val closingTxsCodec: Codec[ClosingTxs] = ( + ("localAndRemote_opt" | optional(bool8, closingTxCodec)) :: + ("localOnly_opt" | optional(bool8, closingTxCodec)) :: + ("remoteOnly_opt" | optional(bool8, closingTxCodec))).as[ClosingTxs] + + val DATA_NEGOTIATING_SIMPLE_14_Codec: Codec[DATA_NEGOTIATING_SIMPLE] = ( + ("commitments" | commitmentsCodec) :: + ("localShutdown" | lengthDelimited(shutdownCodec)) :: + ("remoteShutdown" | lengthDelimited(shutdownCodec)) :: + ("proposedClosingTxs" | listOfN(uint16, closingTxsCodec)) :: + ("publishedClosingTxs" | listOfN(uint16, closingTxCodec))).as[DATA_NEGOTIATING_SIMPLE] + val DATA_CLOSING_07_Codec: Codec[DATA_CLOSING] = ( ("commitments" | commitmentsCodecWithoutFirstRemoteCommitIndex) :: ("waitingSince" | blockHeight) :: @@ -789,6 +801,7 @@ private[channel] object ChannelCodecs4 { // Order matters! val channelDataCodec: Codec[PersistentChannelData] = discriminated[PersistentChannelData].by(uint16) + .typecase(0x14, Codecs.DATA_NEGOTIATING_SIMPLE_14_Codec) .typecase(0x13, Codecs.DATA_WAIT_FOR_DUAL_FUNDING_SIGNED_13_Codec) .typecase(0x12, Codecs.DATA_WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT_12_Codec) .typecase(0x11, Codecs.DATA_CLOSING_11_Codec) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala index 304afa9b48..f102cd3868 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala @@ -84,6 +84,7 @@ object TestDatabases { } case d: DATA_CLOSING => d.copy(commitments = freeze2(d.commitments)) case d: DATA_NEGOTIATING => d.copy(commitments = freeze2(d.commitments)) + case d: DATA_NEGOTIATING_SIMPLE => d.copy(commitments = freeze2(d.commitments)) case d: DATA_SHUTDOWN => d.copy(commitments = freeze2(d.commitments)) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala index 53b9953633..9a9335577e 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala @@ -31,11 +31,12 @@ import fr.acinq.eclair.blockchain.{DummyOnChainWallet, OnChainWallet, OnchainPub import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fsm.Channel import fr.acinq.eclair.channel.publish.TxPublisher -import fr.acinq.eclair.channel.publish.TxPublisher.PublishReplaceableTx +import fr.acinq.eclair.channel.publish.TxPublisher.{PublishFinalTx, PublishReplaceableTx} import fr.acinq.eclair.channel.states.ChannelStateTestsBase.FakeTxPublisherFactory import fr.acinq.eclair.payment.send.SpontaneousRecipient import fr.acinq.eclair.payment.{Invoice, OutgoingPaymentPacket} import fr.acinq.eclair.router.Router.{ChannelHop, HopRelayParams, Route} +import fr.acinq.eclair.testutils.PimpTestProbe.convert import fr.acinq.eclair.transactions.Transactions import fr.acinq.eclair.transactions.Transactions._ import fr.acinq.eclair.wire.protocol._ @@ -91,8 +92,10 @@ object ChannelStateTestsTags { val RejectRbfAttempts = "reject_rbf_attempts" /** If set, the non-initiator will require a 1-block delay between RBF attempts. */ val DelayRbfAttempts = "delay_rbf_attempts" - /** If set, channels will adapt their max HTLC amount to the available balance */ - val AdaptMaxHtlcAmount = "adapt-max-htlc-amount" + /** If set, channels will adapt their max HTLC amount to the available balance. */ + val AdaptMaxHtlcAmount = "adapt_max_htlc_amount" + /** If set, closing will use option_simple_close. */ + val SimpleClose = "option_simple_close" } trait ChannelStateTestsBase extends Assertions with Eventually { @@ -188,6 +191,7 @@ trait ChannelStateTestsBase extends Assertions with Eventually { .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.ZeroConf))(_.updated(Features.ZeroConf, FeatureSupport.Optional)) .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.ScidAlias))(_.updated(Features.ScidAlias, FeatureSupport.Optional)) .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.DualFunding))(_.updated(Features.DualFunding, FeatureSupport.Optional)) + .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.SimpleClose))(_.updated(Features.SimpleClose, FeatureSupport.Optional)) .initFeatures() val bobInitFeatures = Bob.nodeParams.features .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.DisableWumbo))(_.removed(Features.Wumbo)) @@ -200,6 +204,7 @@ trait ChannelStateTestsBase extends Assertions with Eventually { .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.ZeroConf))(_.updated(Features.ZeroConf, FeatureSupport.Optional)) .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.ScidAlias))(_.updated(Features.ScidAlias, FeatureSupport.Optional)) .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.DualFunding))(_.updated(Features.DualFunding, FeatureSupport.Optional)) + .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.SimpleClose))(_.updated(Features.SimpleClose, FeatureSupport.Optional)) .initFeatures() val channelType = ChannelTypes.defaultFromFeatures(aliceInitFeatures, bobInitFeatures, announceChannel = channelFlags.announceChannel) @@ -508,23 +513,41 @@ trait ChannelStateTestsBase extends Assertions with Eventually { s2r.forward(r) r2s.expectMsgType[Shutdown] r2s.forward(s) - // agreeing on a closing fee - var sCloseFee, rCloseFee = 0.sat - do { - sCloseFee = s2r.expectMsgType[ClosingSigned].feeSatoshis + if (s.stateData.asInstanceOf[ChannelDataWithCommitments].commitments.params.localParams.initFeatures.hasFeature(Features.SimpleClose)) { + s2r.expectMsgType[ClosingComplete] s2r.forward(r) - rCloseFee = r2s.expectMsgType[ClosingSigned].feeSatoshis + r2s.expectMsgType[ClosingComplete] + r2s.forward(s) + r2s.expectMsgType[ClosingSig] r2s.forward(s) - } while (sCloseFee != rCloseFee) - s2blockchain.expectMsgType[TxPublisher.PublishTx] - s2blockchain.expectMsgType[WatchTxConfirmed] - r2blockchain.expectMsgType[TxPublisher.PublishTx] - r2blockchain.expectMsgType[WatchTxConfirmed] - eventually { - assert(s.stateName == CLOSING) - assert(r.stateName == CLOSING) + val sTx = r2blockchain.expectMsgType[PublishFinalTx].tx + r2blockchain.expectWatchTxConfirmed(sTx.txid) + s2r.expectMsgType[ClosingSig] + s2r.forward(r) + val rTx = s2blockchain.expectMsgType[PublishFinalTx].tx + s2blockchain.expectWatchTxConfirmed(rTx.txid) + assert(s2blockchain.expectMsgType[PublishFinalTx].tx.txid == sTx.txid) + s2blockchain.expectWatchTxConfirmed(sTx.txid) + assert(r2blockchain.expectMsgType[PublishFinalTx].tx.txid == rTx.txid) + r2blockchain.expectWatchTxConfirmed(rTx.txid) + } else { + // agreeing on a closing fee + var sCloseFee, rCloseFee = 0.sat + do { + sCloseFee = s2r.expectMsgType[ClosingSigned].feeSatoshis + s2r.forward(r) + rCloseFee = r2s.expectMsgType[ClosingSigned].feeSatoshis + r2s.forward(s) + } while (sCloseFee != rCloseFee) + s2blockchain.expectMsgType[TxPublisher.PublishTx] + s2blockchain.expectMsgType[WatchTxConfirmed] + r2blockchain.expectMsgType[TxPublisher.PublishTx] + r2blockchain.expectMsgType[WatchTxConfirmed] + eventually { + assert(s.stateName == CLOSING) + assert(r.stateName == CLOSING) + } } - // both nodes are now in CLOSING state with a mutual close tx pending for confirmation } def localClose(s: TestFSMRef[ChannelState, ChannelData, Channel], s2blockchain: TestProbe): LocalCommitPublished = { @@ -566,7 +589,7 @@ trait ChannelStateTestsBase extends Assertions with Eventually { // we watch the confirmation of the "final" transactions that send funds to our wallets (main delayed output and 2nd stage htlc transactions) assert(s2blockchain.expectMsgType[WatchTxConfirmed].txId == commitTx.txid) localCommitPublished.claimMainDelayedOutputTx.foreach(claimMain => { - val watchConfirmed = s2blockchain.expectMsgType[WatchTxConfirmed] + val watchConfirmed = s2blockchain.expectMsgType[WatchTxConfirmed] assert(watchConfirmed.txId == claimMain.tx.txid) assert(watchConfirmed.delay_opt.map(_.parentTxId).contains(publishedLocalCommitTx.txid)) }) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/f/ShutdownStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/f/ShutdownStateSpec.scala index c7a0258441..0b58a4d109 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/f/ShutdownStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/f/ShutdownStateSpec.scala @@ -20,7 +20,7 @@ import akka.testkit.TestProbe import com.softwaremill.quicklens.ModifyPimp import fr.acinq.bitcoin.ScriptFlags import fr.acinq.bitcoin.scalacompat.Crypto.PrivateKey -import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, Crypto, SatoshiLong, Transaction} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, Crypto, SatoshiLong, Script, Transaction} import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.fee.{FeeratePerKw, FeeratesPerKw} import fr.acinq.eclair.blockchain.{CurrentBlockHeight, CurrentFeerates} @@ -33,7 +33,7 @@ import fr.acinq.eclair.payment.relay.Relayer._ import fr.acinq.eclair.payment.send.SpontaneousRecipient import fr.acinq.eclair.transactions.Transactions.ClaimLocalAnchorOutputTx import fr.acinq.eclair.wire.protocol.{AnnouncementSignatures, ClosingSigned, CommitSig, Error, FailureMessageCodecs, PermanentChannelFailure, RevokeAndAck, Shutdown, UpdateAddHtlc, UpdateFailHtlc, UpdateFailMalformedHtlc, UpdateFee, UpdateFulfillHtlc} -import fr.acinq.eclair.{BlockHeight, CltvExpiry, CltvExpiryDelta, MilliSatoshiLong, TestConstants, TestKitBaseClass, randomBytes32} +import fr.acinq.eclair.{BlockHeight, CltvExpiry, CltvExpiryDelta, MilliSatoshiLong, TestConstants, TestKitBaseClass, randomBytes32, randomKey} import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.{Outcome, Tag} import scodec.bits.ByteVector @@ -911,6 +911,25 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit assert(alice.stateData.asInstanceOf[DATA_SHUTDOWN].closingFeerates.contains(closingFeerates2)) } + test("recv CMD_CLOSE with updated script") { f => + import f._ + val sender = TestProbe() + val script = Script.write(Script.pay2wpkh(randomKey().publicKey)) + alice ! CMD_CLOSE(sender.ref, Some(script), None) + sender.expectMsgType[RES_FAILURE[CMD_CLOSE, ClosingAlreadyInProgress]] + } + + test("recv CMD_CLOSE with updated script (option_simple_close)", Tag(ChannelStateTestsTags.SimpleClose)) { f => + import f._ + val sender = TestProbe() + val script = Script.write(Script.pay2wpkh(randomKey().publicKey)) + alice ! CMD_CLOSE(sender.ref, Some(script), None) + sender.expectMsgType[RES_SUCCESS[CMD_CLOSE]] + assert(alice2bob.expectMsgType[Shutdown].scriptPubKey == script) + alice2bob.forward(bob) + awaitCond(bob.stateData.asInstanceOf[DATA_SHUTDOWN].remoteShutdown.scriptPubKey == script) + } + test("recv CMD_FORCECLOSE") { f => import f._ diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala index c011d66514..226767cd4b 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala @@ -17,7 +17,7 @@ package fr.acinq.eclair.channel.states.g import akka.testkit.TestProbe -import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, Satoshi, SatoshiLong, Transaction} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, Satoshi, SatoshiLong, Script, Transaction} import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.fee.{FeeratePerKw, FeeratesPerKw} import fr.acinq.eclair.channel.Helpers.Closing @@ -26,11 +26,12 @@ import fr.acinq.eclair.channel.fsm.Channel import fr.acinq.eclair.channel.publish.TxPublisher.{PublishFinalTx, PublishTx} import fr.acinq.eclair.channel.states.ChannelStateTestsBase.PimpTestFSM import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags} +import fr.acinq.eclair.testutils.PimpTestProbe._ import fr.acinq.eclair.transactions.Transactions import fr.acinq.eclair.transactions.Transactions.ZeroFeeHtlcTxAnchorOutputsCommitmentFormat import fr.acinq.eclair.wire.protocol.ClosingSignedTlv.FeeRange -import fr.acinq.eclair.wire.protocol.{AnnouncementSignatures, ClosingSigned, Error, Shutdown, TlvStream, Warning} -import fr.acinq.eclair.{BlockHeight, CltvExpiry, Features, MilliSatoshiLong, TestConstants, TestKitBaseClass, randomBytes32} +import fr.acinq.eclair.wire.protocol.{AnnouncementSignatures, ClosingComplete, ClosingSig, ClosingSigned, ClosingTlv, Error, Shutdown, TlvStream, Warning} +import fr.acinq.eclair.{BlockHeight, CltvExpiry, Features, MilliSatoshiLong, TestConstants, TestKitBaseClass, randomBytes32, randomKey} import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.{Outcome, Tag} @@ -63,11 +64,15 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike alice2bob.forward(bob, aliceShutdown) val bobShutdown = bob2alice.expectMsgType[Shutdown] bob2alice.forward(alice, bobShutdown) - awaitCond(alice.stateName == NEGOTIATING) - assert(alice.stateData.asInstanceOf[DATA_NEGOTIATING].commitments.params.localParams.upfrontShutdownScript_opt.forall(_ == aliceShutdown.scriptPubKey)) - - awaitCond(bob.stateName == NEGOTIATING) - assert(bob.stateData.asInstanceOf[DATA_NEGOTIATING].commitments.params.localParams.upfrontShutdownScript_opt.forall(_ == bobShutdown.scriptPubKey)) + if (alice.stateData.asInstanceOf[ChannelDataWithCommitments].commitments.params.localParams.initFeatures.hasFeature(Features.SimpleClose)) { + awaitCond(alice.stateName == NEGOTIATING_SIMPLE) + awaitCond(bob.stateName == NEGOTIATING_SIMPLE) + } else { + awaitCond(alice.stateName == NEGOTIATING) + assert(alice.stateData.asInstanceOf[DATA_NEGOTIATING].commitments.params.localParams.upfrontShutdownScript_opt.forall(_ == aliceShutdown.scriptPubKey)) + awaitCond(bob.stateName == NEGOTIATING) + assert(bob.stateData.asInstanceOf[DATA_NEGOTIATING].commitments.params.localParams.upfrontShutdownScript_opt.forall(_ == bobShutdown.scriptPubKey)) + } } def bobClose(f: FixtureParam, feerates: Option[ClosingFeerates] = None): Unit = { @@ -79,11 +84,15 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike bob2alice.forward(alice, bobShutdown) val aliceShutdown = alice2bob.expectMsgType[Shutdown] alice2bob.forward(bob, aliceShutdown) - awaitCond(alice.stateName == NEGOTIATING) - assert(alice.stateData.asInstanceOf[DATA_NEGOTIATING].commitments.params.localParams.upfrontShutdownScript_opt.forall(_ == aliceShutdown.scriptPubKey)) - - awaitCond(bob.stateName == NEGOTIATING) - assert(bob.stateData.asInstanceOf[DATA_NEGOTIATING].commitments.params.localParams.upfrontShutdownScript_opt.forall(_ == bobShutdown.scriptPubKey)) + if (bob.stateData.asInstanceOf[ChannelDataWithCommitments].commitments.params.localParams.initFeatures.hasFeature(Features.SimpleClose)) { + awaitCond(alice.stateName == NEGOTIATING_SIMPLE) + awaitCond(bob.stateName == NEGOTIATING_SIMPLE) + } else { + awaitCond(alice.stateName == NEGOTIATING) + assert(alice.stateData.asInstanceOf[DATA_NEGOTIATING].commitments.params.localParams.upfrontShutdownScript_opt.forall(_ == aliceShutdown.scriptPubKey)) + awaitCond(bob.stateName == NEGOTIATING) + assert(bob.stateData.asInstanceOf[DATA_NEGOTIATING].commitments.params.localParams.upfrontShutdownScript_opt.forall(_ == bobShutdown.scriptPubKey)) + } } def buildFeerates(feerate: FeeratePerKw, minFeerate: FeeratePerKw = FeeratePerKw(250 sat)): FeeratesPerKw = @@ -473,6 +482,131 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike bob2blockchain.expectMsgType[WatchTxConfirmed] } + test("recv ClosingComplete (both outputs)", Tag(ChannelStateTestsTags.SimpleClose)) { f => + import f._ + aliceClose(f) + val aliceClosingComplete = alice2bob.expectMsgType[ClosingComplete] + assert(aliceClosingComplete.fees > 0.sat) + assert(aliceClosingComplete.closerAndCloseeSig_opt.nonEmpty) + assert(aliceClosingComplete.closerNoCloseeSig_opt.nonEmpty) + assert(aliceClosingComplete.noCloserCloseeSig_opt.isEmpty) + val bobClosingComplete = bob2alice.expectMsgType[ClosingComplete] + assert(bobClosingComplete.fees > 0.sat) + assert(bobClosingComplete.closerAndCloseeSig_opt.nonEmpty) + assert(bobClosingComplete.closerNoCloseeSig_opt.nonEmpty) + assert(bobClosingComplete.noCloserCloseeSig_opt.isEmpty) + + alice2bob.forward(bob, aliceClosingComplete) + val bobClosingSig = bob2alice.expectMsgType[ClosingSig] + bob2alice.forward(alice, bobClosingSig) + val aliceTx = alice2blockchain.expectMsgType[PublishFinalTx] + assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == aliceTx.tx.txid) + assert(aliceTx.desc == "closing") + alice2blockchain.expectWatchTxConfirmed(aliceTx.tx.txid) + bob2blockchain.expectWatchTxConfirmed(aliceTx.tx.txid) + assert(alice.stateName == NEGOTIATING_SIMPLE) + + bob2alice.forward(alice, bobClosingComplete) + val aliceClosingSig = alice2bob.expectMsgType[ClosingSig] + alice2bob.forward(bob, aliceClosingSig) + val bobTx = bob2blockchain.expectMsgType[PublishFinalTx] + assert(alice2blockchain.expectMsgType[PublishFinalTx].tx.txid == bobTx.tx.txid) + assert(aliceTx.tx.txid != bobTx.tx.txid) + assert(bobTx.desc == "closing") + bob2blockchain.expectWatchTxConfirmed(bobTx.tx.txid) + alice2blockchain.expectWatchTxConfirmed(bobTx.tx.txid) + assert(bob.stateName == NEGOTIATING_SIMPLE) + } + + test("recv ClosingComplete (single output)", Tag(ChannelStateTestsTags.SimpleClose), Tag(ChannelStateTestsTags.NoPushAmount)) { f => + import f._ + aliceClose(f) + val closingComplete = alice2bob.expectMsgType[ClosingComplete] + assert(closingComplete.closerAndCloseeSig_opt.isEmpty) + assert(closingComplete.closerNoCloseeSig_opt.nonEmpty) + assert(closingComplete.noCloserCloseeSig_opt.isEmpty) + // Bob has nothing at stake. + bob2alice.expectNoMessage(100 millis) + + alice2bob.forward(bob, closingComplete) + bob2alice.expectMsgType[ClosingSig] + bob2alice.forward(alice) + val closingTx = alice2blockchain.expectMsgType[PublishFinalTx] + assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == closingTx.tx.txid) + alice2blockchain.expectWatchTxConfirmed(closingTx.tx.txid) + bob2blockchain.expectWatchTxConfirmed(closingTx.tx.txid) + assert(alice.stateName == NEGOTIATING_SIMPLE) + assert(bob.stateName == NEGOTIATING_SIMPLE) + } + + test("recv ClosingComplete (single output, trimmed)", Tag(ChannelStateTestsTags.SimpleClose), Tag(ChannelStateTestsTags.NoPushAmount)) { f => + import f._ + val (r, htlc) = addHtlc(250_000 msat, alice, bob, alice2bob, bob2alice) + crossSign(alice, bob, alice2bob, bob2alice) + fulfillHtlc(htlc.id, r, bob, alice, bob2alice, alice2bob) + crossSign(bob, alice, bob2alice, alice2bob) + + aliceClose(f) + val aliceClosingComplete = alice2bob.expectMsgType[ClosingComplete] + assert(aliceClosingComplete.closerAndCloseeSig_opt.isEmpty) + assert(aliceClosingComplete.closerNoCloseeSig_opt.nonEmpty) + assert(aliceClosingComplete.noCloserCloseeSig_opt.isEmpty) + val bobClosingComplete = bob2alice.expectMsgType[ClosingComplete] + assert(bobClosingComplete.closerAndCloseeSig_opt.isEmpty) + assert(bobClosingComplete.closerNoCloseeSig_opt.isEmpty) + assert(bobClosingComplete.noCloserCloseeSig_opt.nonEmpty) + + bob2alice.forward(alice, bobClosingComplete) + val aliceClosingSig = alice2bob.expectMsgType[ClosingSig] + alice2bob.forward(bob, aliceClosingSig) + val bobTx = bob2blockchain.expectMsgType[PublishFinalTx] + assert(alice2blockchain.expectMsgType[PublishFinalTx].tx.txid == bobTx.tx.txid) + bob2blockchain.expectWatchTxConfirmed(bobTx.tx.txid) + alice2blockchain.expectWatchTxConfirmed(bobTx.tx.txid) + assert(alice.stateName == NEGOTIATING_SIMPLE) + assert(bob.stateName == NEGOTIATING_SIMPLE) + } + + test("recv ClosingComplete (missing closee output)", Tag(ChannelStateTestsTags.SimpleClose)) { f => + import f._ + aliceClose(f) + val aliceClosingComplete = alice2bob.expectMsgType[ClosingComplete] + val bobClosingComplete = bob2alice.expectMsgType[ClosingComplete] + alice2bob.forward(bob, aliceClosingComplete.copy(tlvStream = TlvStream(ClosingTlv.CloserNoClosee(aliceClosingComplete.closerNoCloseeSig_opt.get)))) + // Bob expects to receive a signature for a closing transaction containing his output, so he ignores Alice's + // closing_complete instead of sending back his closing_sig. + bob2alice.expectNoMessage(100 millis) + bob2alice.forward(alice, bobClosingComplete) + val aliceClosingSig = alice2bob.expectMsgType[ClosingSig] + alice2bob.forward(bob, aliceClosingSig.copy(tlvStream = TlvStream(ClosingTlv.NoCloserClosee(aliceClosingSig.closerAndCloseeSig_opt.get)))) + bob2alice.expectNoMessage(100 millis) + bob2blockchain.expectNoMessage(100 millis) + } + + test("recv ClosingComplete (with concurrent shutdown)", Tag(ChannelStateTestsTags.SimpleClose)) { f => + import f._ + aliceClose(f) + val aliceClosingComplete1 = alice2bob.expectMsgType[ClosingComplete] + bob2alice.expectMsgType[ClosingComplete] // ignored + // Bob updates his closing script before receiving Alice's closing_complete. + val bobScript = Script.write(Script.pay2wpkh(randomKey().publicKey)) + bob ! CMD_CLOSE(TestProbe().ref, Some(bobScript), Some(ClosingFeerates(preferred = FeeratePerKw(2500 sat), min = FeeratePerKw(253 sat), max = FeeratePerKw(2500 sat)))) + val bobShutdown = bob2alice.expectMsgType[Shutdown] + assert(bobShutdown.scriptPubKey == bobScript) + val bobClosingComplete = bob2alice.expectMsgType[ClosingComplete] + alice2bob.forward(bob, aliceClosingComplete1) + bob2alice.expectNoMessage(100 millis) // Bob ignores Alice's obsolete closing_complete. + // When Alice receives Bob's shutdown, she doesn't change her own closing txs. + bob2alice.forward(alice, bobShutdown) + alice2bob.expectNoMessage(100 millis) + // When she receives Bob's new closing_complete, she signs it: Bob now has closing transactions with his last closing script. + bob2alice.forward(alice, bobClosingComplete) + val aliceClosingSig = alice2bob.expectMsgType[ClosingSig] + alice2bob.forward(bob, aliceClosingSig) + alice2blockchain.expectMsgType[PublishFinalTx] + bob2blockchain.expectMsgType[PublishFinalTx] + } + test("recv WatchFundingSpentTriggered (counterparty's mutual close)") { f => import f._ aliceClose(f) @@ -533,6 +667,94 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike assert(bob.stateName == CLOSING) } + test("recv WatchFundingSpentTriggered (signed closing tx)", Tag(ChannelStateTestsTags.SimpleClose)) { f => + import f._ + bobClose(f) + // Alice and Bob publish a first closing tx. + val aliceClosingComplete1 = alice2bob.expectMsgType[ClosingComplete] + alice2bob.forward(bob, aliceClosingComplete1) + val bobClosingComplete1 = bob2alice.expectMsgType[ClosingComplete] + bob2alice.forward(alice, bobClosingComplete1) + val aliceClosingSig1 = alice2bob.expectMsgType[ClosingSig] + val bobTx1 = alice2blockchain.expectMsgType[PublishFinalTx].tx + alice2blockchain.expectWatchTxConfirmed(bobTx1.txid) + val bobClosingSig1 = bob2alice.expectMsgType[ClosingSig] + val aliceTx1 = bob2blockchain.expectMsgType[PublishFinalTx].tx + bob2blockchain.expectWatchTxConfirmed(aliceTx1.txid) + alice2bob.forward(bob, aliceClosingSig1) + assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == bobTx1.txid) + bob2blockchain.expectWatchTxConfirmed(bobTx1.txid) + bob2alice.forward(alice, bobClosingSig1) + assert(alice2blockchain.expectMsgType[PublishFinalTx].tx.txid == aliceTx1.txid) + alice2blockchain.expectWatchTxConfirmed(aliceTx1.txid) + + // Alice updates her closing script. + alice ! CMD_CLOSE(TestProbe().ref, Some(Script.write(Script.pay2wpkh(randomKey().publicKey))), None) + alice2bob.expectMsgType[Shutdown] + alice2bob.forward(bob) + alice2bob.expectMsgType[ClosingComplete] + alice2bob.forward(bob) + bob2alice.expectMsgType[ClosingSig] + bob2alice.forward(alice) + val aliceTx2 = alice2blockchain.expectMsgType[PublishFinalTx].tx + alice2blockchain.expectWatchTxConfirmed(aliceTx2.txid) + assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == aliceTx2.txid) + bob2blockchain.expectWatchTxConfirmed(aliceTx2.txid) + + // They first receive a watch event for the older transaction, then the new one. + alice ! WatchFundingSpentTriggered(aliceTx1) + alice ! WatchFundingSpentTriggered(bobTx1) + alice ! WatchFundingSpentTriggered(aliceTx2) + alice2blockchain.expectNoMessage(100 millis) + assert(alice.stateName == NEGOTIATING_SIMPLE) + bob ! WatchFundingSpentTriggered(aliceTx1) + bob ! WatchFundingSpentTriggered(bobTx1) + bob ! WatchFundingSpentTriggered(aliceTx2) + bob2blockchain.expectNoMessage(100 millis) + assert(bob.stateName == NEGOTIATING_SIMPLE) + } + + test("recv WatchFundingSpentTriggered (unsigned closing tx)", Tag(ChannelStateTestsTags.SimpleClose)) { f => + import f._ + bobClose(f) + val aliceClosingComplete = alice2bob.expectMsgType[ClosingComplete] + alice2bob.forward(bob, aliceClosingComplete) + val bobClosingComplete = bob2alice.expectMsgType[ClosingComplete] + bob2alice.forward(alice, bobClosingComplete) + alice2bob.expectMsgType[ClosingSig] + val bobTx = alice2blockchain.expectMsgType[PublishFinalTx].tx + alice2blockchain.expectWatchTxConfirmed(bobTx.txid) + bob2alice.expectMsgType[ClosingSig] + val aliceTx = bob2blockchain.expectMsgType[PublishFinalTx].tx + bob2blockchain.expectWatchTxConfirmed(aliceTx.txid) + + alice ! WatchFundingSpentTriggered(aliceTx) + assert(alice2blockchain.expectMsgType[PublishFinalTx].tx.txid == aliceTx.txid) + alice2blockchain.expectWatchTxConfirmed(aliceTx.txid) + alice2blockchain.expectNoMessage(100 millis) + + bob ! WatchFundingSpentTriggered(bobTx) + assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == bobTx.txid) + bob2blockchain.expectWatchTxConfirmed(bobTx.txid) + bob2blockchain.expectNoMessage(100 millis) + } + + test("recv WatchFundingSpentTriggered (unrecognized commit)") { f => + import f._ + bobClose(f) + alice ! WatchFundingSpentTriggered(Transaction(0, Nil, Nil, 0)) + alice2blockchain.expectNoMessage(100 millis) + assert(alice.stateName == NEGOTIATING) + } + + test("recv WatchFundingSpentTriggered (unrecognized commit, option_simple_close)", Tag(ChannelStateTestsTags.SimpleClose)) { f => + import f._ + bobClose(f) + alice ! WatchFundingSpentTriggered(Transaction(0, Nil, Nil, 0)) + alice2blockchain.expectNoMessage(100 millis) + assert(alice.stateName == NEGOTIATING_SIMPLE) + } + test("recv CMD_CLOSE") { f => import f._ bobClose(f) @@ -573,14 +795,6 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike awaitCond(bob.stateName == CLOSING) } - test("recv WatchFundingSpentTriggered (unrecognized commit)") { f => - import f._ - bobClose(f) - alice ! WatchFundingSpentTriggered(Transaction(0, Nil, Nil, 0)) - alice2blockchain.expectNoMessage(100 millis) - assert(alice.stateName == NEGOTIATING) - } - test("recv Error") { f => import f._ bobClose(f) @@ -593,4 +807,28 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == tx.txid) } + test("recv Error (option_simple_close)", Tag(ChannelStateTestsTags.SimpleClose)) { f => + import f._ + aliceClose(f) + val closingComplete = alice2bob.expectMsgType[ClosingComplete] + alice2bob.forward(bob, closingComplete) + bob2alice.expectMsgType[ClosingComplete] + val closingSig = bob2alice.expectMsgType[ClosingSig] + bob2alice.forward(alice, closingSig) + val closingTx = alice2blockchain.expectMsgType[PublishFinalTx].tx + alice2blockchain.expectWatchTxConfirmed(closingTx.txid) + assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == closingTx.txid) + bob2blockchain.expectWatchTxConfirmed(closingTx.txid) + + alice ! Error(ByteVector32.Zeroes, "oops") + awaitCond(alice.stateName == CLOSING) + assert(alice.stateData.asInstanceOf[DATA_CLOSING].mutualClosePublished.nonEmpty) + alice2blockchain.expectNoMessage(100 millis) // we have a mutual close transaction, so we don't publish the commit tx + + bob ! Error(ByteVector32.Zeroes, "oops") + awaitCond(bob.stateName == CLOSING) + assert(bob.stateData.asInstanceOf[DATA_CLOSING].mutualClosePublished.nonEmpty) + bob2blockchain.expectNoMessage(100 millis) // we have a mutual close transaction, so we don't publish the commit tx + } + } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala index ba527991bf..cbb62eb78f 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala @@ -335,6 +335,18 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(alice.stateName == CLOSED) } + test("recv WatchTxConfirmedTriggered (mutual close, option_simple_close)", Tag(ChannelStateTestsTags.SimpleClose)) { f => + import f._ + mutualClose(alice, bob, alice2bob, bob2alice, alice2blockchain, bob2blockchain) + val mutualCloseTx = alice.stateData.asInstanceOf[DATA_NEGOTIATING_SIMPLE].publishedClosingTxs.last + + alice ! WatchTxConfirmedTriggered(BlockHeight(0), 0, mutualCloseTx.tx) + awaitCond(alice.stateName == CLOSED) + + bob ! WatchTxConfirmedTriggered(BlockHeight(0), 0, mutualCloseTx.tx) + awaitCond(bob.stateName == CLOSED) + } + test("recv WatchFundingSpentTriggered (local commit)") { f => import f._ // an error occurs and alice publishes her commit tx @@ -859,6 +871,18 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(!listener.expectMsgType[LocalChannelUpdate].channelUpdate.channelFlags.isEnabled) } + test("recv WatchFundingSpentTriggered (remote commit, option_simple_close)", Tag(ChannelStateTestsTags.SimpleClose)) { f => + import f._ + mutualClose(alice, bob, alice2bob, bob2alice, alice2blockchain, bob2blockchain) + // Bob publishes his last current commit tx, the one it had when entering NEGOTIATING state. + val bobCommitTx = bobCommitTxs.last.commitTx.tx + val closingState = remoteClose(bobCommitTx, alice, alice2blockchain) + assert(closingState.claimHtlcTxs.isEmpty) + val txPublished = txListener.expectMsgType[TransactionPublished] + assert(txPublished.tx == bobCommitTx) + assert(txPublished.miningFee > 0.sat) // alice is funder, she pays the fee for the remote commit + } + test("recv CMD_BUMP_FORCE_CLOSE_FEE (remote commit)", Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => import f._ @@ -904,10 +928,10 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(alice.stateName == CLOSED) } - test("recv WatchTxConfirmedTriggered (remote commit, option_static_remotekey)", Tag(ChannelStateTestsTags.StaticRemoteKey)) { f => + test("recv WatchTxConfirmedTriggered (remote commit, option_static_remotekey)", Tag(ChannelStateTestsTags.StaticRemoteKey), Tag(ChannelStateTestsTags.SimpleClose)) { f => import f._ mutualClose(alice, bob, alice2bob, bob2alice, alice2blockchain, bob2blockchain) - assert(alice.stateData.asInstanceOf[DATA_CLOSING].commitments.params.channelFeatures == ChannelFeatures(Features.StaticRemoteKey)) + assert(alice.stateData.asInstanceOf[DATA_NEGOTIATING_SIMPLE].commitments.params.channelFeatures == ChannelFeatures(Features.StaticRemoteKey)) // bob publishes his last current commit tx, the one it had when entering NEGOTIATING state val bobCommitTx = bobCommitTxs.last.commitTx.tx assert(bobCommitTx.txOut.size == 2) // two main outputs diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/integration/ChannelIntegrationSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/integration/ChannelIntegrationSpec.scala index 5a959adeb4..6c8ecc90b4 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/integration/ChannelIntegrationSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/integration/ChannelIntegrationSpec.scala @@ -580,7 +580,7 @@ class StandardChannelIntegrationSpec extends ChannelIntegrationSpec { fundee.register ! Register.Forward(sender.ref.toTyped[Any], channelId, CMD_CLOSE(sender.ref, None, None)) sender.expectMsgType[RES_SUCCESS[CMD_CLOSE]] // we then wait for C and F to negotiate the closing fee - awaitCond(stateListener.expectMsgType[ChannelStateChanged](max = 60 seconds).currentState == CLOSING, max = 60 seconds) + awaitCond(stateListener.expectMsgType[ChannelStateChanged](max = 60 seconds).currentState == NEGOTIATING_SIMPLE, max = 60 seconds) // and close the channel val bitcoinClient = new BitcoinCoreClient(bitcoinrpcclient) awaitCond({ diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/AsyncPaymentTriggererSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/AsyncPaymentTriggererSpec.scala index 55d0335b3e..665db36d0b 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/AsyncPaymentTriggererSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/AsyncPaymentTriggererSpec.scala @@ -10,7 +10,7 @@ import com.typesafe.config.ConfigFactory import fr.acinq.bitcoin.scalacompat.ByteVector32 import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.eclair.blockchain.CurrentBlockHeight -import fr.acinq.eclair.channel.NEGOTIATING +import fr.acinq.eclair.channel.{NEGOTIATING, NEGOTIATING_SIMPLE} import fr.acinq.eclair.io.Switchboard.GetPeerInfo import fr.acinq.eclair.io.{Peer, PeerConnected, PeerReadyManager, Switchboard} import fr.acinq.eclair.payment.relay.AsyncPaymentTriggerer._ @@ -166,7 +166,7 @@ class AsyncPaymentTriggererSpec extends ScalaTestWithActorTestKit(ConfigFactory. system.eventStream ! EventStream.Publish(PeerConnected(peer.ref.toClassic, remoteNodeId, null)) val request2 = switchboard.expectMessageType[Switchboard.GetPeerInfo] request2.replyTo ! Peer.PeerInfo(peer.ref.toClassic, remoteNodeId, Peer.CONNECTED, None, None, Set(TestProbe().ref.toClassic)) - peer.expectMessageType[Peer.GetPeerChannels].replyTo ! Peer.PeerChannels(remoteNodeId, Seq(Peer.ChannelInfo(null, NEGOTIATING, null))) + peer.expectMessageType[Peer.GetPeerChannels].replyTo ! Peer.PeerChannels(remoteNodeId, Seq(Peer.ChannelInfo(null, NEGOTIATING_SIMPLE, null))) probe.expectNoMessage(100 millis) probe2.expectMessage(AsyncPaymentTriggered) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TransactionsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TransactionsSpec.scala index 951c62146b..840ca7517f 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TransactionsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TransactionsSpec.scala @@ -19,7 +19,7 @@ package fr.acinq.eclair.transactions import fr.acinq.bitcoin.SigHash._ import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, ripemd160, sha256} import fr.acinq.bitcoin.scalacompat.Script.{pay2wpkh, pay2wsh, write} -import fr.acinq.bitcoin.scalacompat.{Btc, ByteVector32, Crypto, MilliBtc, MilliBtcDouble, OutPoint, Protocol, Satoshi, SatoshiLong, Script, ScriptWitness, Transaction, TxId, TxIn, TxOut, millibtc2satoshi} +import fr.acinq.bitcoin.scalacompat.{Btc, ByteVector32, Crypto, MilliBtc, MilliBtcDouble, OP_PUSHDATA, OP_RETURN, OutPoint, Protocol, Satoshi, SatoshiLong, Script, ScriptWitness, Transaction, TxId, TxIn, TxOut, millibtc2satoshi} import fr.acinq.eclair.TestUtils.randomTxId import fr.acinq.eclair._ import fr.acinq.eclair.blockchain.fee.{ConfirmationTarget, FeeratePerKw} @@ -828,6 +828,56 @@ class TransactionsSpec extends AnyFunSuite with Logging { val toRemoteIndex = (toLocal.index + 1) % 2 assert(closingTx.tx.txOut(toRemoteIndex.toInt).amount == 250_000.sat) } + { + // Different amounts, both outputs untrimmed, local is closer (option_simple_close): + val spec = CommitmentSpec(Set.empty, feeratePerKw, 150_000_000 msat, 250_000_000 msat) + val closingTxs = makeSimpleClosingTxs(commitInput, spec, SimpleClosingTxFee.PaidByUs(5_000 sat), 0, localPubKeyScript, remotePubKeyScript) + assert(closingTxs.localAndRemote_opt.nonEmpty) + assert(closingTxs.localOnly_opt.nonEmpty) + assert(closingTxs.remoteOnly_opt.isEmpty) + val localAndRemote = closingTxs.localAndRemote_opt.flatMap(_.toLocalOutput).get + assert(localAndRemote.publicKeyScript == localPubKeyScript) + assert(localAndRemote.amount == 145_000.sat) + val localOnly = closingTxs.localOnly_opt.flatMap(_.toLocalOutput).get + assert(localOnly.publicKeyScript == localPubKeyScript) + assert(localOnly.amount == 145_000.sat) + } + { + // Remote is using OP_RETURN (option_simple_close): we set their output amount to 0 sat. + val spec = CommitmentSpec(Set.empty, feeratePerKw, 150_000_000 msat, 1_500_000 msat) + val remotePubKeyScript = Script.write(OP_RETURN :: OP_PUSHDATA(hex"deadbeef") :: Nil) + val closingTxs = makeSimpleClosingTxs(commitInput, spec, SimpleClosingTxFee.PaidByUs(5_000 sat), 0, localPubKeyScript, remotePubKeyScript) + assert(closingTxs.localAndRemote_opt.nonEmpty) + assert(closingTxs.localOnly_opt.nonEmpty) + assert(closingTxs.remoteOnly_opt.isEmpty) + val localAndRemote = closingTxs.localAndRemote_opt.flatMap(_.toLocalOutput).get + assert(localAndRemote.publicKeyScript == localPubKeyScript) + assert(localAndRemote.amount == 145_000.sat) + val remoteOutput = closingTxs.localAndRemote_opt.get.tx.txOut((localAndRemote.index.toInt + 1) % 2) + assert(remoteOutput.amount == 0.sat) + assert(remoteOutput.publicKeyScript == remotePubKeyScript) + val localOnly = closingTxs.localOnly_opt.flatMap(_.toLocalOutput).get + assert(localOnly.publicKeyScript == localPubKeyScript) + assert(localOnly.amount == 145_000.sat) + } + { + // Remote is using OP_RETURN (option_simple_close) and paying the fees: we set their output amount to 0 sat. + val spec = CommitmentSpec(Set.empty, feeratePerKw, 150_000_000 msat, 10_000_000 msat) + val remotePubKeyScript = Script.write(OP_RETURN :: OP_PUSHDATA(hex"deadbeef") :: Nil) + val closingTxs = makeSimpleClosingTxs(commitInput, spec, SimpleClosingTxFee.PaidByThem(5_000 sat), 0, localPubKeyScript, remotePubKeyScript) + assert(closingTxs.localAndRemote_opt.nonEmpty) + assert(closingTxs.localOnly_opt.nonEmpty) + assert(closingTxs.remoteOnly_opt.isEmpty) + val localAndRemote = closingTxs.localAndRemote_opt.flatMap(_.toLocalOutput).get + assert(localAndRemote.publicKeyScript == localPubKeyScript) + assert(localAndRemote.amount == 150_000.sat) + val remoteOutput = closingTxs.localAndRemote_opt.get.tx.txOut((localAndRemote.index.toInt + 1) % 2) + assert(remoteOutput.amount == 0.sat) + assert(remoteOutput.publicKeyScript == remotePubKeyScript) + val localOnly = closingTxs.localOnly_opt.flatMap(_.toLocalOutput).get + assert(localOnly.publicKeyScript == localPubKeyScript) + assert(localOnly.amount == 150_000.sat) + } { // Same amounts, both outputs untrimmed, local is fundee: val spec = CommitmentSpec(Set.empty, feeratePerKw, 150_000_000 msat, 150_000_000 msat) @@ -851,6 +901,29 @@ class TransactionsSpec extends AnyFunSuite with Logging { assert(toLocal.amount == 150_000.sat) assert(toLocal.index == 0) } + { + // Their output is trimmed (option_simple_close): + val spec = CommitmentSpec(Set.empty, feeratePerKw, 150_000_000 msat, 1_000_000 msat) + val closingTxs = makeSimpleClosingTxs(commitInput, spec, SimpleClosingTxFee.PaidByThem(800 sat), 0, localPubKeyScript, remotePubKeyScript) + assert(closingTxs.all.size == 1) + assert(closingTxs.localOnly_opt.nonEmpty) + val toLocal = closingTxs.localOnly_opt.flatMap(_.toLocalOutput).get + assert(toLocal.publicKeyScript == localPubKeyScript) + assert(toLocal.amount == 150_000.sat) + assert(toLocal.index == 0) + } + { + // Their OP_RETURN output is trimmed (option_simple_close): + val spec = CommitmentSpec(Set.empty, feeratePerKw, 150_000_000 msat, 1_000_000 msat) + val remotePubKeyScript = Script.write(OP_RETURN :: OP_PUSHDATA(hex"deadbeef") :: Nil) + val closingTxs = makeSimpleClosingTxs(commitInput, spec, SimpleClosingTxFee.PaidByThem(1_001 sat), 0, localPubKeyScript, remotePubKeyScript) + assert(closingTxs.all.size == 1) + assert(closingTxs.localOnly_opt.nonEmpty) + val toLocal = closingTxs.localOnly_opt.flatMap(_.toLocalOutput).get + assert(toLocal.publicKeyScript == localPubKeyScript) + assert(toLocal.amount == 150_000.sat) + assert(toLocal.index == 0) + } { // Our output is trimmed: val spec = CommitmentSpec(Set.empty, feeratePerKw, 50_000 msat, 150_000_000 msat) @@ -858,6 +931,14 @@ class TransactionsSpec extends AnyFunSuite with Logging { assert(closingTx.tx.txOut.length == 1) assert(closingTx.toLocalOutput.isEmpty) } + { + // Our output is trimmed (option_simple_close): + val spec = CommitmentSpec(Set.empty, feeratePerKw, 1_000_000 msat, 150_000_000 msat) + val closingTxs = makeSimpleClosingTxs(commitInput, spec, SimpleClosingTxFee.PaidByUs(800 sat), 0, localPubKeyScript, remotePubKeyScript) + assert(closingTxs.all.size == 1) + assert(closingTxs.remoteOnly_opt.nonEmpty) + assert(closingTxs.remoteOnly_opt.flatMap(_.toLocalOutput).isEmpty) + } { // Both outputs are trimmed: val spec = CommitmentSpec(Set.empty, feeratePerKw, 50_000 msat, 10_000 msat)