From e05d16e66898ec23561996867c432df9311a0731 Mon Sep 17 00:00:00 2001 From: t-bast Date: Wed, 4 Oct 2023 11:45:24 +0200 Subject: [PATCH] Implement the `option_simple_close` protocol We introduce a new `NEGOTIATING_SIMPLE` state where we exchange the `closing_complete` and `closing_sig` messages, and allow RBF-ing previous transactions and updating our closing script. We stay in that state until one of the transactions confirms, or a force close is detected. This is important to ensure we're able to correctly reconnect and negotiate RBF candidates. We keep this separate from the previous NEGOTIATING state to make it easier to remove support for the older mutual close protocols once we're confident the network has been upgraded. --- .../acinq/eclair/balance/CheckBalance.scala | 1 + .../fr/acinq/eclair/channel/ChannelData.scala | 10 + .../eclair/channel/ChannelExceptions.scala | 2 + .../fr/acinq/eclair/channel/Helpers.scala | 88 ++++++ .../fr/acinq/eclair/channel/fsm/Channel.scala | 207 ++++++++++--- .../eclair/channel/fsm/CommonHandlers.scala | 1 + .../eclair/channel/fsm/ErrorHandlers.scala | 12 + .../acinq/eclair/io/PeerReadyNotifier.scala | 1 + .../acinq/eclair/json/JsonSerializers.scala | 1 + .../eclair/transactions/Transactions.scala | 52 ++++ .../channel/version4/ChannelCodecs4.scala | 13 + .../scala/fr/acinq/eclair/TestDatabases.scala | 1 + .../ChannelStateTestsHelperMethods.scala | 59 ++-- .../channel/states/f/ShutdownStateSpec.scala | 23 +- .../states/g/NegotiatingStateSpec.scala | 289 ++++++++++++++++-- .../channel/states/h/ClosingStateSpec.scala | 30 +- .../relay/AsyncPaymentTriggererSpec.scala | 4 +- .../transactions/TransactionsSpec.scala | 37 ++- 18 files changed, 745 insertions(+), 86 deletions(-) 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 4c2f725587..9556f91d98 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 41a2bc8eb0..27818dfa84 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 @@ -73,6 +73,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 @@ -602,6 +603,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.isInitiator || 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 54407e9863..7a72bf9d87 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 @@ -107,6 +107,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: ByteVector32, 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: ByteVector32) 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: ByteVector32) extends ChannelException(channelId, s"invalid close signature: txId=$txId") case class InvalidCloseAmountBelowDust (override val channelId: ByteVector32, txId: ByteVector32) 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 c2d00bfe62..b528dcfb25 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)) } @@ -688,6 +689,93 @@ object Helpers { } } + /** We are the closer: we sign closing transactions for which we pay the fees. */ + def makeSimpleClosingTx(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") + val closingFee = { + val dummyClosingTxs = Transactions.makeSimpleClosingTxs(commitment.commitInput, commitment.localCommit.spec, SimpleClosingTxFee.PaidByUs(0 sat), 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)) + } + } + val closingTxs = Transactions.makeSimpleClosingTxs(commitment.commitInput, commitment.localCommit.spec, closingFee, 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, 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, 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 7ca6098c0d..8245dbd2f9 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 @@ -731,10 +731,19 @@ 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.isInitiator) { + // 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 = nodeParams.onChainFeeConf.getClosingFeerate(nodeParams.currentFeerates) + MutualClose.makeSimpleClosingTx(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)) => + goto(NEGOTIATING_SIMPLE) using DATA_NEGOTIATING_SIMPLE(d.commitments, localShutdown, remoteShutdown, closingTxs :: Nil, Nil) storing() sending sendList :+ closingComplete + } + } else if (d.commitments.params.localParams.isInitiator) { // we are the channel initiator, need to initiate the negotiation by sending the first closing_signed - val (closingTx, closingSigned) = Closing.MutualClose.makeFirstClosingTx(keyManager, d.commitments.latest, localShutdown.scriptPubKey, remoteShutdownScript, nodeParams.currentFeerates, nodeParams.onChainFeeConf, d.closingFeerates) + val (closingTx, closingSigned) = MutualClose.makeFirstClosingTx(keyManager, d.commitments.latest, localShutdown.scriptPubKey, remoteShutdownScript, nodeParams.currentFeerates, 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 @@ -1300,9 +1309,18 @@ 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.isInitiator) { + if (Features.canUseFeature(d.commitments.params.localParams.initFeatures, d.commitments.params.remoteParams.initFeatures, Features.SimpleClose)) { + val closingFeerate = nodeParams.onChainFeeConf.getClosingFeerate(nodeParams.currentFeerates) + MutualClose.makeSimpleClosingTx(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)) => + 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.isInitiator) { // we are the channel initiator, need to initiate the negotiation by sending the first closing_signed - val (closingTx, closingSigned) = Closing.MutualClose.makeFirstClosingTx(keyManager, commitments1.latest, localShutdown.scriptPubKey, remoteShutdown.scriptPubKey, nodeParams.currentFeerates, nodeParams.onChainFeeConf, closingFeerates) + val (closingTx, closingSigned) = MutualClose.makeFirstClosingTx(keyManager, commitments1.latest, localShutdown.scriptPubKey, remoteShutdown.scriptPubKey, nodeParams.currentFeerates, 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 @@ -1342,9 +1360,18 @@ 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.isInitiator) { + if (Features.canUseFeature(d.commitments.params.localParams.initFeatures, d.commitments.params.remoteParams.initFeatures, Features.SimpleClose)) { + val closingFeerate = nodeParams.onChainFeeConf.getClosingFeerate(nodeParams.currentFeerates) + MutualClose.makeSimpleClosingTx(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)) => + goto(NEGOTIATING_SIMPLE) using DATA_NEGOTIATING_SIMPLE(d.commitments, localShutdown, remoteShutdown, closingTxs :: Nil, Nil) storing() sending closingComplete + } + } else if (d.commitments.params.localParams.isInitiator) { // we are the channel initiator, need to initiate the negotiation by sending the first closing_signed - val (closingTx, closingSigned) = Closing.MutualClose.makeFirstClosingTx(keyManager, commitments1.latest, localShutdown.scriptPubKey, remoteShutdown.scriptPubKey, nodeParams.currentFeerates, nodeParams.onChainFeeConf, closingFeerates) + val (closingTx, closingSigned) = MutualClose.makeFirstClosingTx(keyManager, commitments1.latest, localShutdown.scriptPubKey, remoteShutdown.scriptPubKey, nodeParams.currentFeerates, 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 @@ -1359,6 +1386,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) @@ -1366,17 +1402,18 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with case Event(c: CurrentFeerates, 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) @@ -1384,17 +1421,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)) { @@ -1417,7 +1454,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with case Some(ClosingSignedTlv.FeeRange(minFee, maxFee)) if !d.commitments.params.localParams.isInitiator => // if we are not the channel initiator 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.currentFeerates, nodeParams.onChainFeeConf) + val localClosingFees = MutualClose.firstClosingFee(d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, nodeParams.currentFeerates, 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}") @@ -1432,7 +1469,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)) @@ -1444,9 +1481,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.currentFeerates, 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.currentFeerates, 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)) @@ -1475,7 +1512,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.currentFeerates, nodeParams.onChainFeeConf, Some(feerates)) + val (closingTx, closingSigned) = MutualClose.makeFirstClosingTx(keyManager, d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, nodeParams.currentFeerates, nodeParams.onChainFeeConf, Some(feerates)) val closingTxProposed1 = d.closingTxProposed match { case previousNegotiations :+ currentNegotiation => previousNegotiations :+ (currentNegotiation :+ ClosingTxProposed(closingTx, closingSigned)) case previousNegotiations => previousNegotiations :+ List(ClosingTxProposed(closingTx, closingSigned)) @@ -1486,10 +1523,100 @@ 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) => + if (remoteShutdown.scriptPubKey != d.remoteShutdown.scriptPubKey) { + // Our peer changed their closing script: we sign a new version of our closing transaction using the new script. + val feerate = nodeParams.onChainFeeConf.getClosingFeerate(nodeParams.currentFeerates) + MutualClose.makeSimpleClosingTx(keyManager, d.commitments.latest, d.localShutdown.scriptPubKey, remoteShutdown.scriptPubKey, feerate) match { + case Left(_) => stay() using d.copy(remoteShutdown = remoteShutdown) storing() + case Right((closingTxs, closingComplete)) => stay() using d.copy(remoteShutdown = remoteShutdown, proposedClosingTxs = d.proposedClosingTxs :+ closingTxs) storing() sending closingComplete + } + } else { + stay() + } + + 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)) => + val d1 = d.copy(publishedClosingTxs = d.publishedClosingTxs :+ signedClosingTx) + stay() using d1 storing() calling doPublish(signedClosingTx, isInitiator = 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) => + val d1 = d.copy(publishedClosingTxs = d.publishedClosingTxs :+ signedClosingTx) + stay() using d1 storing() calling doPublish(signedClosingTx, isInitiator = 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 + stay() using d.copy(publishedClosingTxs = d.publishedClosingTxs :+ closingTx) storing() calling doPublish(closingTx, isInitiator = 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.currentFeerates)) + MutualClose.makeSimpleClosingTx(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) + 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 { @@ -2080,6 +2207,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.currentFeerates) + Closing.MutualClose.makeSimpleClosingTx(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 @@ -2235,6 +2371,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) } @@ -2262,6 +2399,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 } @@ -2269,15 +2407,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 @@ -2351,7 +2480,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 e994ff0745..24dcc729f1 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") { @@ -201,6 +209,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) @@ -245,6 +254,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) @@ -263,6 +273,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)) } @@ -302,6 +313,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/PeerReadyNotifier.scala b/eclair-core/src/main/scala/fr/acinq/eclair/io/PeerReadyNotifier.scala index b5812fbb78..5eef823946 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 @@ -192,6 +192,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 dccf8fbaf6..de662e57fb 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 @@ -593,6 +593,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 be64073544..f774bf4af1 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 @@ -827,6 +827,58 @@ object Transactions { ClosingTx(commitTxInput, tx, toLocalOutput) } + // @formatter:off + sealed trait SimpleClosingTxFee + object SimpleClosingTxFee { + case class PaidByUs(fee: Satoshi) extends SimpleClosingTxFee + case class PaidByThem(fee: Satoshi) extends SimpleClosingTxFee + } + // @formatter:on + + case class ClosingTxs(localAndRemote_opt: Option[ClosingTx], localOnly_opt: Option[ClosingTx], remoteOnly_opt: Option[ClosingTx]) { + 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 + } + + def makeSimpleClosingTxs(input: InputInfo, spec: CommitmentSpec, fee: SimpleClosingTxFee, 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, 0) + + 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) + } + + val toLocalOutput_opt = if (toLocalAmount >= dustLimit(localScriptPubKey)) Some(TxOut(toLocalAmount, localScriptPubKey)) else None + val toRemoteOutput_opt = if (toRemoteAmount >= dustLimit(remoteScriptPubKey)) Some(TxOut(toRemoteAmount, remoteScriptPubKey)) else None + + (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, toLocalAmount, 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, toLocalAmount, 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, toLocalAmount, 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 fd301d0208..d810f0b829 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 @@ -592,6 +592,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_0a_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" | commitmentsCodec) :: ("waitingSince" | blockHeight) :: @@ -611,6 +623,7 @@ private[channel] object ChannelCodecs4 { // Order matters! val channelDataCodec: Codec[PersistentChannelData] = discriminated[PersistentChannelData].by(uint16) + .typecase(0x0a, Codecs.DATA_NEGOTIATING_SIMPLE_0a_Codec) .typecase(0x09, Codecs.DATA_WAIT_FOR_DUAL_FUNDING_SIGNED_09_Codec) .typecase(0x08, Codecs.DATA_WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT_08_Codec) .typecase(0x07, Codecs.DATA_CLOSING_07_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 2243a6a0f6..85c0a4268f 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala @@ -85,6 +85,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 142e4a2a45..529ebff73e 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 @@ -28,15 +28,16 @@ import fr.acinq.eclair._ import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.fee.{FeeratePerKw, FeeratesPerKw} import fr.acinq.eclair.blockchain.{DummyOnChainWallet, OnChainWallet, OnchainPubkeyCache, SingleKeyOnChainWallet} +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.channel._ import fr.acinq.eclair.payment.OutgoingPaymentPacket.Upstream 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._ @@ -92,8 +93,10 @@ object ChannelStateTestsTags { val DelayRbfAttempts = "delay_rbf_attempts" /** If set, peers will support the quiesce protocol. */ val Quiescence = "quiescence" - /** 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 { @@ -187,6 +190,7 @@ trait ChannelStateTestsBase extends Assertions with Eventually { .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.DualFunding))(_.updated(Features.DualFunding, FeatureSupport.Optional)) .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.Splicing))(_.updated(Features.SplicePrototype, FeatureSupport.Optional)) .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.Quiescence))(_.updated(Features.Quiescence, 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)) @@ -201,6 +205,7 @@ trait ChannelStateTestsBase extends Assertions with Eventually { .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.DualFunding))(_.updated(Features.DualFunding, FeatureSupport.Optional)) .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.Splicing))(_.updated(Features.SplicePrototype, FeatureSupport.Optional)) .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.Quiescence))(_.updated(Features.Quiescence, FeatureSupport.Optional)) + .modify(_.activated).usingIf(tags.contains(ChannelStateTestsTags.SimpleClose))(_.updated(Features.SimpleClose, FeatureSupport.Optional)) .initFeatures() val channelType = ChannelTypes.defaultFromFeatures(aliceInitFeatures, bobInitFeatures, announceChannel = channelFlags.announceChannel) @@ -502,23 +507,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) - } 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) + r2s.expectMsgType[ClosingSig] + r2s.forward(s) + 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 = { 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 890204f3e7..c7cb72ce69 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._ import fr.acinq.eclair.payment.relay.Relayer._ import fr.acinq.eclair.payment.send.SpontaneousRecipient import fr.acinq.eclair.wire.protocol.{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 @@ -873,6 +873,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 23283f19b4..7cb5598505 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.{ClosingSigned, Error, Shutdown, TlvStream, Warning} -import fr.acinq.eclair.{CltvExpiry, Features, MilliSatoshiLong, TestConstants, TestKitBaseClass, randomBytes32} +import fr.acinq.eclair.wire.protocol.{ClosingComplete, ClosingSig, ClosingSigned, ClosingTlv, Error, Shutdown, TlvStream, Warning} +import fr.acinq.eclair.{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 = @@ -444,6 +453,130 @@ 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) + bob2alice.expectMsgType[ClosingComplete] // ignored + alice2bob.forward(bob, aliceClosingComplete1) + bob2alice.expectNoMessage(100 millis) // Bob ignores Alice's obsolete closing_complete. + // When Alice receives Bob's shutdown, she generates a new set of closing transactions using the new closing script. + bob2alice.forward(alice, bobShutdown) + val aliceClosingComplete2 = alice2bob.expectMsgType[ClosingComplete] + alice2bob.forward(bob, aliceClosingComplete2) + val bobClosingSig = bob2alice.expectMsgType[ClosingSig] + bob2blockchain.expectMsgType[PublishFinalTx] + bob2alice.forward(alice, bobClosingSig) + alice2blockchain.expectMsgType[PublishFinalTx] + } + test("recv WatchFundingSpentTriggered (counterparty's mutual close)") { f => import f._ aliceClose(f) @@ -504,6 +637,104 @@ 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[ClosingComplete] + bob2alice.forward(alice) + bob2alice.expectMsgType[ClosingSig] + bob2alice.forward(alice) + alice2bob.expectMsgType[ClosingSig] + alice2bob.forward(bob) + val bobTx2 = alice2blockchain.expectMsgType[PublishFinalTx].tx + alice2blockchain.expectWatchTxConfirmed(bobTx2.txid) + val aliceTx2 = bob2blockchain.expectMsgType[PublishFinalTx].tx + bob2blockchain.expectWatchTxConfirmed(aliceTx2.txid) + assert(alice2blockchain.expectMsgType[PublishFinalTx].tx.txid == aliceTx2.txid) + alice2blockchain.expectWatchTxConfirmed(aliceTx2.txid) + assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == bobTx2.txid) + bob2blockchain.expectWatchTxConfirmed(bobTx2.txid) + + // They first receive a watch event for the older transaction, then the new one. + alice ! WatchFundingSpentTriggered(aliceTx1) + alice ! WatchFundingSpentTriggered(bobTx1) + alice ! WatchFundingSpentTriggered(aliceTx2) + alice ! WatchFundingSpentTriggered(bobTx2) + alice2blockchain.expectNoMessage(100 millis) + assert(alice.stateName == NEGOTIATING_SIMPLE) + bob ! WatchFundingSpentTriggered(aliceTx1) + bob ! WatchFundingSpentTriggered(bobTx1) + bob ! WatchFundingSpentTriggered(aliceTx2) + bob ! WatchFundingSpentTriggered(bobTx2) + 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) @@ -544,14 +775,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) @@ -564,4 +787,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 fe4ac6bf17..75c54e05c3 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 @@ -332,6 +332,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 @@ -737,6 +749,20 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(txPublished.miningFee > 0.sat) // alice is funder, she pays the fee for the remote commit } + test("recv WatchFundingSpentTriggered (remote commit, option_simple_close)", Tag(ChannelStateTestsTags.SimpleClose)) { f => + import f._ + mutualClose(alice, bob, alice2bob, bob2alice, alice2blockchain, bob2blockchain) + val initialState = alice.stateData.asInstanceOf[DATA_NEGOTIATING_SIMPLE] + // 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.claimMainOutputTx.nonEmpty) + 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._ @@ -782,10 +808,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/payment/relay/AsyncPaymentTriggererSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/AsyncPaymentTriggererSpec.scala index 116a225e43..97b3d9142d 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 @@ -9,7 +9,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, Switchboard} import fr.acinq.eclair.payment.relay.AsyncPaymentTriggerer._ @@ -152,7 +152,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, 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 3dfcae02ea..0e74c3a824 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 @@ -827,6 +827,20 @@ 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), 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) + } { // Same amounts, both outputs untrimmed, local is fundee: val spec = CommitmentSpec(Set.empty, feeratePerKw, 150_000_000 msat, 150_000_000 msat) @@ -850,19 +864,38 @@ 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), 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) val closingTx = makeClosingTx(commitInput, localPubKeyScript, remotePubKeyScript, localIsInitiator = true, localDustLimit, 1000 sat, spec) assert(closingTx.tx.txOut.length == 1) - assert(closingTx.toLocalOutput == None) + 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), 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) val closingTx = makeClosingTx(commitInput, localPubKeyScript, remotePubKeyScript, localIsInitiator = true, localDustLimit, 1000 sat, spec) assert(closingTx.tx.txOut.isEmpty) - assert(closingTx.toLocalOutput == None) + assert(closingTx.toLocalOutput.isEmpty) } }