diff --git a/docs/release-notes/eclair-vnext.md b/docs/release-notes/eclair-vnext.md index 5562da1032..867837acc5 100644 --- a/docs/release-notes/eclair-vnext.md +++ b/docs/release-notes/eclair-vnext.md @@ -4,7 +4,17 @@ ## Major changes - +### 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= +``` ### API changes diff --git a/eclair-core/src/main/resources/reference.conf b/eclair-core/src/main/resources/reference.conf index 77dd6f058c..771a2711e0 100644 --- a/eclair-core/src/main/resources/reference.conf +++ b/eclair-core/src/main/resources/reference.conf @@ -81,6 +81,7 @@ eclair { // node that you trust using override-init-features (see below). option_zeroconf = disabled keysend = disabled + option_simple_close=optional trampoline_payment_prototype = disabled async_payment_prototype = disabled on_the_fly_funding = disabled diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Features.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Features.scala index c9886b031e..b95843e81c 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Features.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Features.scala @@ -300,6 +300,11 @@ object Features { val mandatory = 54 } + case object SimpleClose extends Feature with InitFeature with NodeFeature { + val rfcName = "option_simple_close" + val mandatory = 60 + } + // TODO: @t-bast: update feature bits once spec-ed (currently reserved here: https://github.com/lightningnetwork/lightning-rfc/issues/605) // We're not advertising these bits yet in our announcements, clients have to assume support. // This is why we haven't added them yet to `areSupported`. @@ -363,6 +368,7 @@ object Features { PaymentMetadata, ZeroConf, KeySend, + SimpleClose, TrampolinePaymentPrototype, AsyncPaymentPrototype, SplicePrototype, @@ -380,6 +386,7 @@ object Features { RouteBlinding -> (VariableLengthOnion :: Nil), TrampolinePaymentPrototype -> (PaymentSecret :: Nil), KeySend -> (VariableLengthOnion :: Nil), + SimpleClose -> (ShutdownAnySegwit :: Nil), AsyncPaymentPrototype -> (TrampolinePaymentPrototype :: Nil), OnTheFlyFunding -> (SplicePrototype :: Nil), FundingFeeCredit -> (OnTheFlyFunding :: Nil) 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 3b0221542d..ffd4b13c68 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 @@ -26,7 +26,7 @@ import fr.acinq.eclair.channel.fund.{InteractiveTxBuilder, InteractiveTxSigningS import fr.acinq.eclair.io.Peer import fr.acinq.eclair.transactions.CommitmentSpec import fr.acinq.eclair.transactions.Transactions._ -import fr.acinq.eclair.wire.protocol.{ChannelAnnouncement, ChannelReady, ChannelReestablish, ChannelUpdate, ClosingSigned, CommitSig, FailureReason, FundingCreated, FundingSigned, Init, LiquidityAds, OnionRoutingPacket, OpenChannel, OpenDualFundedChannel, Shutdown, SpliceInit, Stfu, TxInitRbf, TxSignatures, UpdateAddHtlc, UpdateFailHtlc, UpdateFailMalformedHtlc, UpdateFulfillHtlc} +import fr.acinq.eclair.wire.protocol.{ChannelAnnouncement, ChannelReady, ChannelReestablish, ChannelUpdate, ClosingComplete, ClosingSig, ClosingSigned, CommitSig, FailureReason, FundingCreated, FundingSigned, Init, LiquidityAds, OnionRoutingPacket, OpenChannel, OpenDualFundedChannel, Shutdown, SpliceInit, Stfu, TxInitRbf, TxSignatures, UpdateAddHtlc, UpdateFailHtlc, UpdateFailMalformedHtlc, UpdateFulfillHtlc} import fr.acinq.eclair.{Alias, BlockHeight, CltvExpiry, CltvExpiryDelta, Features, InitFeature, MilliSatoshi, MilliSatoshiLong, RealShortChannelId, TimestampMilli, UInt64} import scodec.bits.ByteVector @@ -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 @@ -535,6 +536,20 @@ object SpliceStatus { case object SpliceAborted extends SpliceStatus } +sealed trait ClosingNegotiation { + def localShutdown: Shutdown + /** Closing feerate for our closing transaction. */ + def closingFeerate: FeeratePerKw +} +object ClosingNegotiation { + /** We've sent a new shutdown message: we wait for their shutdown message before taking any action. */ + case class WaitingForRemoteShutdown(localShutdown: Shutdown, closingFeerate: FeeratePerKw) extends ClosingNegotiation + /** We've exchanged shutdown messages: we both send closing_complete to renew the closing transactions. */ + case class SigningTransactions(localShutdown: Shutdown, remoteShutdown: Shutdown, closingFeerate: FeeratePerKw, closingCompleteSent_opt: Option[ClosingComplete], closingSigSent_opt: Option[ClosingSig], closingSigReceived_opt: Option[ClosingSig]) extends ClosingNegotiation + /** We've signed new closing transactions and are waiting for confirmation or to initiate RBF. */ + case class WaitingForConfirmation(localShutdown: Shutdown, remoteShutdown: Shutdown, closingFeerate: FeeratePerKw) extends ClosingNegotiation +} + sealed trait ChannelData extends PossiblyHarmful { def channelId: ByteVector32 } @@ -653,6 +668,16 @@ 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, + status: ClosingNegotiation, + // 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 { + val localScriptPubKey: ByteVector = status.localShutdown.scriptPubKey + 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 90ff6f3ccf..12dddeb955 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 @@ -116,7 +116,11 @@ 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 ShutdownWaitingForSigs (override val channelId: ByteVector32) extends ChannelException(channelId, "received unexpected shutdown while signing closing transactions") +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 UnexpectedClosingComplete (override val channelId: ByteVector32, fees: Satoshi, lockTime: Long) extends ChannelException(channelId, s"unexpected closing_complete with fees=$fees and lockTime=$lockTime: we already sent closing_sig, you must send shutdown first") 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") case class HtlcSigCountMismatch (override val channelId: ByteVector32, expected: Int, actual: Int) extends ChannelException(channelId, s"htlc sig count mismatch: expected=$expected actual=$actual") diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala index 2cbe214432..a491ee7d69 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala @@ -112,10 +112,11 @@ case class ChannelParams(channelId: ByteVector32, // README: if we set our bitcoin node to generate taproot addresses and our peer does not support option_shutdown_anysegwit, we will not be able to mutual-close // channels as the isValidFinalScriptPubkey() check would fail. val allowAnySegwit = Features.canUseFeature(localParams.initFeatures, remoteParams.initFeatures, Features.ShutdownAnySegwit) + val allowOpReturn = Features.canUseFeature(localParams.initFeatures, remoteParams.initFeatures, Features.SimpleClose) val mustUseUpfrontShutdownScript = channelFeatures.hasFeature(Features.UpfrontShutdownScript) // we only enforce using the pre-generated shutdown script if option_upfront_shutdown_script is set if (mustUseUpfrontShutdownScript && localParams.upfrontShutdownScript_opt.exists(_ != localScriptPubKey)) Left(InvalidFinalScript(channelId)) - else if (!Closing.MutualClose.isValidFinalScriptPubkey(localScriptPubKey, allowAnySegwit)) Left(InvalidFinalScript(channelId)) + else if (!Closing.MutualClose.isValidFinalScriptPubkey(localScriptPubKey, allowAnySegwit, allowOpReturn)) Left(InvalidFinalScript(channelId)) else Right(localScriptPubKey) } @@ -126,10 +127,11 @@ case class ChannelParams(channelId: ByteVector32, def validateRemoteShutdownScript(remoteScriptPubKey: ByteVector): Either[ChannelException, ByteVector] = { // to check whether shutdown_any_segwit is active we check features in local and remote parameters, which are negotiated each time we connect to our peer. val allowAnySegwit = Features.canUseFeature(localParams.initFeatures, remoteParams.initFeatures, Features.ShutdownAnySegwit) + val allowOpReturn = Features.canUseFeature(localParams.initFeatures, remoteParams.initFeatures, Features.SimpleClose) val mustUseUpfrontShutdownScript = channelFeatures.hasFeature(Features.UpfrontShutdownScript) // we only enforce using the pre-generated shutdown script if option_upfront_shutdown_script is set if (mustUseUpfrontShutdownScript && remoteParams.upfrontShutdownScript_opt.exists(_ != remoteScriptPubKey)) Left(InvalidFinalScript(channelId)) - else if (!Closing.MutualClose.isValidFinalScriptPubkey(remoteScriptPubKey, allowAnySegwit)) Left(InvalidFinalScript(channelId)) + else if (!Closing.MutualClose.isValidFinalScriptPubkey(remoteScriptPubKey, allowAnySegwit, allowOpReturn)) Left(InvalidFinalScript(channelId)) else Right(remoteScriptPubKey) } 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 78db6d35d0..12b3b2cd90 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)) } @@ -67,14 +68,15 @@ object Helpers { private def extractShutdownScript(channelId: ByteVector32, localFeatures: Features[InitFeature], remoteFeatures: Features[InitFeature], upfrontShutdownScript_opt: Option[ByteVector]): Either[ChannelException, Option[ByteVector]] = { val canUseUpfrontShutdownScript = Features.canUseFeature(localFeatures, remoteFeatures, Features.UpfrontShutdownScript) val canUseAnySegwit = Features.canUseFeature(localFeatures, remoteFeatures, Features.ShutdownAnySegwit) - extractShutdownScript(channelId, canUseUpfrontShutdownScript, canUseAnySegwit, upfrontShutdownScript_opt) + val canUseOpReturn = Features.canUseFeature(localFeatures, remoteFeatures, Features.SimpleClose) + extractShutdownScript(channelId, canUseUpfrontShutdownScript, canUseAnySegwit, canUseOpReturn, upfrontShutdownScript_opt) } - private def extractShutdownScript(channelId: ByteVector32, hasOptionUpfrontShutdownScript: Boolean, allowAnySegwit: Boolean, upfrontShutdownScript_opt: Option[ByteVector]): Either[ChannelException, Option[ByteVector]] = { + private def extractShutdownScript(channelId: ByteVector32, hasOptionUpfrontShutdownScript: Boolean, allowAnySegwit: Boolean, allowOpReturn: Boolean, upfrontShutdownScript_opt: Option[ByteVector]): Either[ChannelException, Option[ByteVector]] = { (hasOptionUpfrontShutdownScript, upfrontShutdownScript_opt) match { case (true, None) => Left(MissingUpfrontShutdownScript(channelId)) case (true, Some(script)) if script.isEmpty => Right(None) // but the provided script can be empty - case (true, Some(script)) if !Closing.MutualClose.isValidFinalScriptPubkey(script, allowAnySegwit) => Left(InvalidFinalScript(channelId)) + case (true, Some(script)) if !Closing.MutualClose.isValidFinalScriptPubkey(script, allowAnySegwit, allowOpReturn) => Left(InvalidFinalScript(channelId)) case (true, Some(script)) => Right(Some(script)) case (false, Some(_)) => Right(None) // they provided a script but the feature is not active, we just ignore it case _ => Right(None) @@ -640,13 +642,14 @@ object Helpers { object MutualClose { - def isValidFinalScriptPubkey(scriptPubKey: ByteVector, allowAnySegwit: Boolean): Boolean = { + def isValidFinalScriptPubkey(scriptPubKey: ByteVector, allowAnySegwit: Boolean, allowOpReturn: Boolean): Boolean = { Try(Script.parse(scriptPubKey)) match { case Success(OP_DUP :: OP_HASH160 :: OP_PUSHDATA(pubkeyHash, _) :: OP_EQUALVERIFY :: OP_CHECKSIG :: Nil) if pubkeyHash.size == 20 => true case Success(OP_HASH160 :: OP_PUSHDATA(scriptHash, _) :: OP_EQUAL :: Nil) if scriptHash.size == 20 => true case Success(OP_0 :: OP_PUSHDATA(pubkeyHash, _) :: Nil) if pubkeyHash.size == 20 => true case Success(OP_0 :: OP_PUSHDATA(scriptHash, _) :: Nil) if scriptHash.size == 32 => true case Success((OP_1 | OP_2 | OP_3 | OP_4 | OP_5 | OP_6 | OP_7 | OP_8 | OP_9 | OP_10 | OP_11 | OP_12 | OP_13 | OP_14 | OP_15 | OP_16) :: OP_PUSHDATA(program, _) :: Nil) if allowAnySegwit && 2 <= program.length && program.length <= 40 => true + case Success(OP_RETURN :: _) if allowOpReturn => true case _ => false } } @@ -707,22 +710,100 @@ 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)] = { + // 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. * The various dust limits are detailed in https://github.com/lightningnetwork/lightning-rfc/blob/master/03-transactions.md#dust-limits */ def checkClosingDustAmounts(closingTx: ClosingTx): Boolean = { - closingTx.tx.txOut.forall(txOut => { - Try(Script.parse(txOut.publicKeyScript)) match { - case Success(OP_DUP :: OP_HASH160 :: OP_PUSHDATA(pubkeyHash, _) :: OP_EQUALVERIFY :: OP_CHECKSIG :: Nil) if pubkeyHash.size == 20 => txOut.amount >= 546.sat - case Success(OP_HASH160 :: OP_PUSHDATA(scriptHash, _) :: OP_EQUAL :: Nil) if scriptHash.size == 20 => txOut.amount >= 540.sat - case Success(OP_0 :: OP_PUSHDATA(pubkeyHash, _) :: Nil) if pubkeyHash.size == 20 => txOut.amount >= 294.sat - case Success(OP_0 :: OP_PUSHDATA(scriptHash, _) :: Nil) if scriptHash.size == 32 => txOut.amount >= 330.sat - case Success((OP_1 | OP_2 | OP_3 | OP_4 | OP_5 | OP_6 | OP_7 | OP_8 | OP_9 | OP_10 | OP_11 | OP_12 | OP_13 | OP_14 | OP_15 | OP_16) :: OP_PUSHDATA(program, _) :: Nil) if 2 <= program.length && program.length <= 40 => txOut.amount >= 354.sat - case _ => txOut.amount >= 546.sat - } - }) + closingTx.tx.txOut.forall(txOut => txOut.amount >= Transactions.dustLimit(txOut.publicKeyScript)) } } 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 06a9d83ea9..fe7ac214d0 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,12 @@ 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)) { + startSimpleClose(d.commitments, localShutdown, remoteShutdown, d.closingFeerates, sendList) + } 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 +1515,11 @@ 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)) { + startSimpleClose(d.commitments, localShutdown, remoteShutdown, d.closingFeerates, revocation :: 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 +1559,11 @@ 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)) { + startSimpleClose(d.commitments, localShutdown, remoteShutdown, d.closingFeerates, 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 closingSigned } else { // we are not the channel initiator, will wait for their closing_signed @@ -1572,6 +1578,12 @@ 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() + case Event(r: RevocationTimeout, d: DATA_SHUTDOWN) => handleRevocationTimeout(r, d) case Event(ProcessCurrentBlockHeight(c), d: DATA_SHUTDOWN) => handleNewBlock(c, d) @@ -1579,17 +1591,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 +1610,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 +1643,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 +1658,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 +1670,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 +1701,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 +1712,159 @@ 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) => + val localScript = d.status.localShutdown.scriptPubKey + val remoteScript = remoteShutdown.scriptPubKey + d.status match { + case status: ClosingNegotiation.WaitingForRemoteShutdown => + // We have already sent our shutdown. Now that we've received theirs, we're ready to sign closing transactions. + MutualClose.makeSimpleClosingTx(nodeParams.currentBlockHeight, keyManager, d.commitments.latest, localScript, remoteScript, status.closingFeerate) match { + case Left(f) => + log.warning("cannot create local closing txs, waiting for remote closing_complete: {}", f.getMessage) + val status1 = ClosingNegotiation.SigningTransactions(status.localShutdown, remoteShutdown, status.closingFeerate, None, None, None) + stay() using d.copy(status = status1) + case Right((closingTxs, closingComplete)) => + log.debug("signing local mutual close transactions: {}", closingTxs) + val status1 = ClosingNegotiation.SigningTransactions(status.localShutdown, remoteShutdown, status.closingFeerate, Some(closingComplete), None, None) + stay() using d.copy(status = status1, proposedClosingTxs = d.proposedClosingTxs :+ closingTxs) storing() sending closingComplete + } + case _: ClosingNegotiation.SigningTransactions => + // We were in the middle of signing transactions: sending shutdown is forbidden at that point. + context.system.scheduler.scheduleOnce(2 second, peer, Peer.Disconnect(remoteNodeId)) + stay() sending Warning(d.channelId, ShutdownWaitingForSigs(d.channelId).getMessage) + case status: ClosingNegotiation.WaitingForConfirmation => + // 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 use the same parameters as we did in the previous signing round. + val localShutdown = status.localShutdown + MutualClose.makeSimpleClosingTx(nodeParams.currentBlockHeight, keyManager, d.commitments.latest, localScript, remoteScript, status.closingFeerate) match { + case Left(f) => + log.warning("cannot create local closing txs, waiting for remote closing_complete: {}", f.getMessage) + val status1 = ClosingNegotiation.SigningTransactions(localShutdown, remoteShutdown, status.closingFeerate, None, None, None) + stay() using d.copy(status = status1) sending localShutdown + case Right((closingTxs, closingComplete)) => + log.debug("signing local mutual close transactions: {}", closingTxs) + val status1 = ClosingNegotiation.SigningTransactions(localShutdown, remoteShutdown, status.closingFeerate, Some(closingComplete), None, None) + stay() using d.copy(status = status1, proposedClosingTxs = d.proposedClosingTxs :+ closingTxs) storing() sending Seq(localShutdown, closingComplete) + } + } + + case Event(closingComplete: ClosingComplete, d: DATA_NEGOTIATING_SIMPLE) => + d.status match { + case _: ClosingNegotiation.WaitingForRemoteShutdown => + log.info("ignoring remote closing_complete, we've sent shutdown to initiate a new signing round") + stay() sending Warning(d.channelId, UnexpectedClosingComplete(d.channelId, closingComplete.fees, closingComplete.lockTime).getMessage) + case _: ClosingNegotiation.WaitingForConfirmation => + log.info("ignoring closing_complete, we've already sent closing_sig: peer must send shutdown again before closing_complete") + stay() sending Warning(d.channelId, UnexpectedClosingComplete(d.channelId, closingComplete.fees, closingComplete.lockTime).getMessage) + case status: ClosingNegotiation.SigningTransactions if status.closingSigSent_opt.nonEmpty => + log.info("ignoring closing_complete, we've already sent closing_sig: peer must send closing_sig, then shutdown again before closing_complete") + stay() sending Warning(d.channelId, UnexpectedClosingComplete(d.channelId, closingComplete.fees, closingComplete.lockTime).getMessage) + case status: ClosingNegotiation.SigningTransactions => + val localScript = status.localShutdown.scriptPubKey + val remoteScript = status.remoteShutdown.scriptPubKey + MutualClose.signSimpleClosingTx(keyManager, d.commitments.latest, localScript, remoteScript, closingComplete) match { + case Left(f) => + log.warning("invalid closing_complete: {}", f.getMessage) + stay() sending Warning(d.channelId, f.getMessage) + case Right((signedClosingTx, closingSig)) => + log.debug("signing remote mutual close transaction: {}", signedClosingTx.tx) + val status1 = status.closingCompleteSent_opt match { + // We've sent closing_complete: we may be waiting for their closing_sig. + case Some(_) => status.closingSigReceived_opt match { + case Some(_) => ClosingNegotiation.WaitingForConfirmation(status.localShutdown, status.remoteShutdown, status.closingFeerate) + case None => status.copy(closingSigSent_opt = Some(closingSig)) + } + // We haven't sent closing_complete: we're not waiting for their closing_sig. + case None => ClosingNegotiation.WaitingForConfirmation(status.localShutdown, status.remoteShutdown, status.closingFeerate) + } + val d1 = d.copy(status = status1, publishedClosingTxs = d.publishedClosingTxs :+ signedClosingTx) + stay() using d1 storing() calling doPublish(signedClosingTx, localPaysClosingFees = false) sending closingSig + } + } + + case Event(closingSig: ClosingSig, d: DATA_NEGOTIATING_SIMPLE) => + d.status match { + case _: ClosingNegotiation.WaitingForRemoteShutdown => + log.info("ignoring remote closing_sig, we've sent shutdown to initiate a new signing round") + stay() + case _: ClosingNegotiation.WaitingForConfirmation => + log.info("ignoring closing_sig, we've already fully signed closing transactions") + stay() + case status: ClosingNegotiation.SigningTransactions if status.closingSigReceived_opt.nonEmpty => + log.info("ignoring closing_sig, we've already received it") + stay() + case status: ClosingNegotiation.SigningTransactions => + MutualClose.receiveSimpleClosingSig(keyManager, d.commitments.latest, d.proposedClosingTxs.last, closingSig) match { + case Left(f) => + log.warning("invalid closing_sig: {}", f.getMessage) + stay() sending Warning(d.channelId, f.getMessage) + case Right(signedClosingTx) => + log.debug("received signatures for local mutual close transaction: {}", signedClosingTx.tx) + val status1 = status.closingSigSent_opt match { + // We have already signed their transaction: both local and remote closing transactions have been updated. + case Some(_) => ClosingNegotiation.WaitingForConfirmation(status.localShutdown, status.remoteShutdown, status.closingFeerate) + // We haven't sent closing_sig yet: they may send us closing_complete to update their closing transaction. + case None => status.copy(closingSigReceived_opt = Some(closingSig)) + } + val d1 = d.copy(status = status1, 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 = Shutdown(d.channelId, c.scriptPubKey.getOrElse(d.status.localShutdown.scriptPubKey)) + val closingFeerate = c.feerates.map(_.preferred).getOrElse(nodeParams.onChainFeeConf.getClosingFeerate(nodeParams.currentBitcoinCoreFeerates)) + d.status match { + case _: ClosingNegotiation.WaitingForRemoteShutdown => + log.info("we're already waiting for our peer to send their shutdown message, no need to send ours again") + handleCommandError(ClosingAlreadyInProgress(d.channelId), c) + case _: ClosingNegotiation.SigningTransactions => + log.info("we're in the middle of signing closing transactions, we should finish this round before starting a new signing session") + handleCommandError(ClosingAlreadyInProgress(d.channelId), c) + case _: ClosingNegotiation.WaitingForConfirmation => + val status1 = ClosingNegotiation.WaitingForRemoteShutdown(localShutdown, closingFeerate) + stay() using d.copy(status = status1) storing() sending localShutdown + } + + case Event(e: Error, d: DATA_NEGOTIATING_SIMPLE) => handleRemoteError(e, d) + + case Event(INPUT_DISCONNECTED, d: DATA_NEGOTIATING_SIMPLE) => + val status1 = ClosingNegotiation.WaitingForRemoteShutdown(d.status.localShutdown, d.status.closingFeerate) + goto(OFFLINE) using d.copy(status = status1) + + }) + when(CLOSING)(handleExceptions { case Event(c: HtlcSettlementCommand, d: DATA_CLOSING) => (c match { @@ -2352,6 +2514,12 @@ 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. + val localShutdown = d.status.localShutdown + val status1 = ClosingNegotiation.WaitingForRemoteShutdown(localShutdown, d.status.closingFeerate) + goto(NEGOTIATING_SIMPLE) using d.copy(status = status1) sending localShutdown + // 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 +2675,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 +2703,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 +2711,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 +2784,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 { @@ -2969,7 +3130,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with if (fundingContribution < 0.sat && parentCommitment.localCommit.spec.toLocal + fundingContribution < parentCommitment.localChannelReserve(d.commitments.params).max(commitTxFees)) { log.warning(s"cannot do splice: insufficient funds (commitTxFees=$commitTxFees reserve=${parentCommitment.localChannelReserve(d.commitments.params)})") Left(InvalidSpliceRequest(d.channelId)) - } else if (cmd.spliceOut_opt.map(_.scriptPubKey).exists(!MutualClose.isValidFinalScriptPubkey(_, allowAnySegwit = true))) { + } else if (cmd.spliceOut_opt.map(_.scriptPubKey).exists(!MutualClose.isValidFinalScriptPubkey(_, allowAnySegwit = true, allowOpReturn = false))) { log.warning("cannot do splice: invalid splice-out script") Left(InvalidSpliceRequest(d.channelId)) } else { 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..ab883425dd 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 @@ -16,13 +16,14 @@ package fr.acinq.eclair.channel.fsm -import akka.actor.{ActorRef, FSM, Status} +import akka.actor.FSM import fr.acinq.bitcoin.scalacompat.{ByteVector32, Script} import fr.acinq.eclair.Features +import fr.acinq.eclair.channel.Helpers.Closing.MutualClose import fr.acinq.eclair.channel._ import fr.acinq.eclair.db.PendingCommandsDb import fr.acinq.eclair.io.Peer -import fr.acinq.eclair.wire.protocol.{HtlcSettlementMessage, LightningMessage, UpdateMessage} +import fr.acinq.eclair.wire.protocol.{HtlcSettlementMessage, LightningMessage, Shutdown, UpdateMessage} import scodec.bits.ByteVector import scala.concurrent.duration.DurationInt @@ -106,6 +107,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.localScriptPubKey case d: DATA_CLOSING => d.finalScriptPubKey case d => d.commitments.params.localParams.upfrontShutdownScript_opt match { @@ -130,4 +132,22 @@ trait CommonHandlers { finalScriptPubKey } + def startSimpleClose(commitments: Commitments, localShutdown: Shutdown, remoteShutdown: Shutdown, closingFeerates: Option[ClosingFeerates], toSend: List[LightningMessage]) = { + val localScript = localShutdown.scriptPubKey + val remoteScript = remoteShutdown.scriptPubKey + val closingFeerate = closingFeerates.map(_.preferred).getOrElse(nodeParams.onChainFeeConf.getClosingFeerate(nodeParams.currentBitcoinCoreFeerates)) + MutualClose.makeSimpleClosingTx(nodeParams.currentBlockHeight, keyManager, commitments.latest, localScript, remoteScript, closingFeerate) match { + case Left(f) => + log.warning("cannot create local closing txs, waiting for remote closing_complete: {}", f.getMessage) + val status = ClosingNegotiation.SigningTransactions(localShutdown, remoteShutdown, closingFeerate, None, None, None) + val d = DATA_NEGOTIATING_SIMPLE(commitments, status, Nil, Nil) + goto(NEGOTIATING_SIMPLE) using d storing() sending toSend + case Right((closingTxs, closingComplete)) => + log.debug("signing local mutual close transactions: {}", closingTxs) + val status = ClosingNegotiation.SigningTransactions(localShutdown, remoteShutdown, closingFeerate, Some(closingComplete), None, None) + val d = DATA_NEGOTIATING_SIMPLE(commitments, status, closingTxs :: Nil, Nil) + goto(NEGOTIATING_SIMPLE) using d storing() sending toSend :+ closingComplete + } + } + } 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 cb6ebe1005..d1bffd374e 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.localScriptPubKey, 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.localScriptPubKey, 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") { @@ -211,6 +219,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) @@ -257,6 +266,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) @@ -275,6 +285,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)) } @@ -314,6 +325,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/channel/fund/InteractiveTxBuilder.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala index 836ceb9d44..29f41c4a7b 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala @@ -578,7 +578,7 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon Left(OutputBelowDust(fundingParams.channelId, addOutput.serialId, addOutput.amount, fundingParams.dustLimit)) } else if (addOutput.pubkeyScript == fundingPubkeyScript && addOutput.amount != fundingParams.fundingAmount) { Left(InvalidSharedOutputAmount(fundingParams.channelId, addOutput.serialId, addOutput.amount, fundingParams.fundingAmount)) - } else if (!MutualClose.isValidFinalScriptPubkey(addOutput.pubkeyScript, allowAnySegwit = true)) { + } else if (!MutualClose.isValidFinalScriptPubkey(addOutput.pubkeyScript, allowAnySegwit = true, allowOpReturn = false)) { Left(InvalidSpliceOutputScript(fundingParams.channelId, addOutput.serialId, addOutput.pubkeyScript)) } else if (addOutput.pubkeyScript == fundingPubkeyScript) { Right(Output.Shared(addOutput.serialId, addOutput.pubkeyScript, purpose.previousLocalBalance + fundingParams.localContribution, purpose.previousRemoteBalance + fundingParams.remoteContribution, purpose.htlcBalance)) 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 2f390f13b9..f8caa38bba 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 286a93dafe..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 @@ -30,7 +30,7 @@ import fr.acinq.eclair.wire.protocol.UpdateAddHtlc import scodec.bits.ByteVector import java.nio.ByteOrder -import scala.util.Try +import scala.util.{Success, Try} /** * Created by PM on 15/12/2016. @@ -231,6 +231,27 @@ object Transactions { */ def fee2rate(fee: Satoshi, weight: Int): FeeratePerKw = FeeratePerKw((fee * 1000L) / weight) + /** As defined in https://github.com/lightning/bolts/blob/master/03-transactions.md#dust-limits */ + def dustLimit(scriptPubKey: ByteVector): Satoshi = { + Try(Script.parse(scriptPubKey)) match { + case Success(OP_DUP :: OP_HASH160 :: OP_PUSHDATA(pubkeyHash, _) :: OP_EQUALVERIFY :: OP_CHECKSIG :: Nil) if pubkeyHash.size == 20 => 546.sat + case Success(OP_HASH160 :: OP_PUSHDATA(scriptHash, _) :: OP_EQUAL :: Nil) if scriptHash.size == 20 => 540.sat + case Success(OP_0 :: OP_PUSHDATA(pubkeyHash, _) :: Nil) if pubkeyHash.size == 20 => 294.sat + case Success(OP_0 :: OP_PUSHDATA(scriptHash, _) :: Nil) if scriptHash.size == 32 => 330.sat + case Success((OP_1 | OP_2 | OP_3 | OP_4 | OP_5 | OP_6 | OP_7 | OP_8 | OP_9 | OP_10 | OP_11 | OP_12 | OP_13 | OP_14 | OP_15 | OP_16) :: OP_PUSHDATA(program, _) :: Nil) if 2 <= program.length && program.length <= 40 => 354.sat + case Success(OP_RETURN :: _) => 0.sat // OP_RETURN is never dust + case _ => 546.sat + } + } + + /** When an output is using OP_RETURN, we usually want to make sure its amount is 0, otherwise bitcoind won't accept it. */ + def isOpReturn(scriptPubKey: ByteVector): Boolean = { + Try(Script.parse(scriptPubKey)) match { + case Success(OP_RETURN :: _) => true + case _ => false + } + } + /** Offered HTLCs below this amount will be trimmed. */ def offeredHtlcTrimThreshold(dustLimit: Satoshi, spec: CommitmentSpec, commitmentFormat: CommitmentFormat): Satoshi = dustLimit + weight2fee(spec.htlcTxFeerate(commitmentFormat), commitmentFormat.htlcTimeoutWeight) @@ -814,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..53ee59a77b 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 @@ -681,7 +681,7 @@ private[channel] object ChannelCodecs4 { ("remotePushAmount" | millisatoshi) :: ("status" | interactiveTxWaitingForSigsCodec) :: ("remoteChannelData_opt" | optional(bool8, varsizebinarydata))).as[DATA_WAIT_FOR_DUAL_FUNDING_SIGNED] - + val DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED_02_Codec: Codec[DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED] = ( ("commitments" | commitmentsCodecWithoutFirstRemoteCommitIndex) :: ("localPushAmount" | millisatoshi) :: @@ -754,6 +754,27 @@ 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] + + private val waitingForRemoteShutdownCodec: Codec[ClosingNegotiation.WaitingForRemoteShutdown] = ( + ("localShutdown" | lengthDelimited(shutdownCodec)) :: + ("closingFeerate" | feeratePerKw) + ).as[ClosingNegotiation.WaitingForRemoteShutdown] + + val closingNegotiationCodec: Codec[ClosingNegotiation] = discriminated[ClosingNegotiation].by(uint8) + .\(0x01) { case status: ClosingNegotiation.WaitingForRemoteShutdown => status }(waitingForRemoteShutdownCodec) + .\(0x02) { case status: ClosingNegotiation.SigningTransactions => ClosingNegotiation.WaitingForRemoteShutdown(status.localShutdown, status.closingFeerate) }(waitingForRemoteShutdownCodec) + .\(0x03) { case status: ClosingNegotiation.WaitingForConfirmation => ClosingNegotiation.WaitingForRemoteShutdown(status.localShutdown, status.closingFeerate) }(waitingForRemoteShutdownCodec) + + val DATA_NEGOTIATING_SIMPLE_14_Codec: Codec[DATA_NEGOTIATING_SIMPLE] = ( + ("commitments" | commitmentsCodec) :: + ("status" | closingNegotiationCodec) :: + ("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 +810,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/main/scala/fr/acinq/eclair/wire/protocol/ChannelTlv.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/ChannelTlv.scala index 7d0fa016f2..ff57406ca0 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/ChannelTlv.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/ChannelTlv.scala @@ -16,7 +16,7 @@ package fr.acinq.eclair.wire.protocol -import fr.acinq.bitcoin.scalacompat.{Satoshi, TxId} +import fr.acinq.bitcoin.scalacompat.{ByteVector64, Satoshi, TxId} import fr.acinq.eclair.channel.{ChannelType, ChannelTypes} import fr.acinq.eclair.wire.protocol.CommonCodecs._ import fr.acinq.eclair.wire.protocol.TlvCodecs.{tlvField, tlvStream, tmillisatoshi} @@ -270,3 +270,23 @@ object ClosingSignedTlv { ) } + +sealed trait ClosingTlv extends Tlv + +object ClosingTlv { + /** Signature for a closing transaction containing only the closer's output. */ + case class CloserNoClosee(sig: ByteVector64) extends ClosingTlv + + /** Signature for a closing transaction containing only the closee's output. */ + case class NoCloserClosee(sig: ByteVector64) extends ClosingTlv + + /** Signature for a closing transaction containing the closer and closee's outputs. */ + case class CloserAndClosee(sig: ByteVector64) extends ClosingTlv + + val closingTlvCodec: Codec[TlvStream[ClosingTlv]] = tlvStream(discriminated[ClosingTlv].by(varint) + .typecase(UInt64(1), tlvField(bytes64.as[CloserNoClosee])) + .typecase(UInt64(2), tlvField(bytes64.as[NoCloserClosee])) + .typecase(UInt64(3), tlvField(bytes64.as[CloserAndClosee])) + ) + +} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala index 9e6128d0a6..05a2e0802a 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecs.scala @@ -227,6 +227,16 @@ object LightningMessageCodecs { ("signature" | bytes64) :: ("tlvStream" | ClosingSignedTlv.closingSignedTlvCodec)).as[ClosingSigned] + val closingCompleteCodec: Codec[ClosingComplete] = ( + ("channelId" | bytes32) :: + ("fees" | satoshi) :: + ("lockTime" | uint32) :: + ("tlvStream" | ClosingTlv.closingTlvCodec)).as[ClosingComplete] + + val closingSigCodec: Codec[ClosingSig] = ( + ("channelId" | bytes32) :: + ("tlvStream" | ClosingTlv.closingTlvCodec)).as[ClosingSig] + val updateAddHtlcCodec: Codec[UpdateAddHtlc] = ( ("channelId" | bytes32) :: ("id" | uint64overflow) :: @@ -487,6 +497,8 @@ object LightningMessageCodecs { .typecase(36, channelReadyCodec) .typecase(38, shutdownCodec) .typecase(39, closingSignedCodec) + .typecase(40, closingCompleteCodec) + .typecase(41, closingSigCodec) .typecase(64, openDualFundedChannelCodec) .typecase(65, acceptDualFundedChannelCodec) .typecase(66, txAddInputCodec) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala index 40cc0633c1..a584441b0f 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/LightningMessageTypes.scala @@ -362,6 +362,18 @@ case class ClosingSigned(channelId: ByteVector32, val feeRange_opt = tlvStream.get[ClosingSignedTlv.FeeRange] } +case class ClosingComplete(channelId: ByteVector32, fees: Satoshi, lockTime: Long, tlvStream: TlvStream[ClosingTlv] = TlvStream.empty) extends ChannelMessage with HasChannelId { + val closerNoCloseeSig_opt: Option[ByteVector64] = tlvStream.get[ClosingTlv.CloserNoClosee].map(_.sig) + val noCloserCloseeSig_opt: Option[ByteVector64] = tlvStream.get[ClosingTlv.NoCloserClosee].map(_.sig) + val closerAndCloseeSig_opt: Option[ByteVector64] = tlvStream.get[ClosingTlv.CloserAndClosee].map(_.sig) +} + +case class ClosingSig(channelId: ByteVector32, tlvStream: TlvStream[ClosingTlv] = TlvStream.empty) extends ChannelMessage with HasChannelId { + val closerNoCloseeSig_opt: Option[ByteVector64] = tlvStream.get[ClosingTlv.CloserNoClosee].map(_.sig) + val noCloserCloseeSig_opt: Option[ByteVector64] = tlvStream.get[ClosingTlv.NoCloserClosee].map(_.sig) + val closerAndCloseeSig_opt: Option[ByteVector64] = tlvStream.get[ClosingTlv.CloserAndClosee].map(_.sig) +} + case class UpdateAddHtlc(channelId: ByteVector32, id: Long, amountMsat: MilliSatoshi, 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..3aacd874fe 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestDatabases.scala @@ -79,17 +79,25 @@ object TestDatabases { case d: DATA_WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT => d.copy(commitments = freeze2(d.commitments)) case d: DATA_NORMAL => d.copy(commitments = freeze2(d.commitments)) .modify(_.spliceStatus).using { - case s: SpliceStatus.SpliceWaitingForSigs => s - case _ => SpliceStatus.NoSplice - } + case s: SpliceStatus.SpliceWaitingForSigs => s + case _ => SpliceStatus.NoSplice + } 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)) } + // When negotiating closing transactions with the option_simple_close feature, we discard pending signatures on + // disconnection and will restart a signing round on reconnection. + def freeze4(input: PersistentChannelData): PersistentChannelData = input match { + case d: DATA_NEGOTIATING_SIMPLE => freeze3(d.copy(status = ClosingNegotiation.WaitingForRemoteShutdown(d.status.localShutdown, d.status.closingFeerate))) + case d => freeze3(d) + } + super.addOrUpdateChannel(data) val check = super.getChannel(data.channelId) - val frozen = freeze3(data) + val frozen = freeze4(data) require(check.contains(frozen), s"serialization/deserialization check failed, $check != $frozen") } } @@ -132,6 +140,7 @@ object TestDatabases { } object TestPgDatabases { + import _root_.io.zonky.test.db.postgres.embedded.EmbeddedPostgres /** single instance */ diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/HelpersSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/HelpersSpec.scala index 52b3d7bf4d..a1b1655c07 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/HelpersSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/HelpersSpec.scala @@ -225,6 +225,7 @@ class HelpersSpec extends TestKitBaseClass with AnyFunSuiteLike with ChannelStat TxOut(294 sat, OP_0 :: OP_PUSHDATA(hex"0000000000000000000000000000000000000000") :: Nil), TxOut(330 sat, OP_0 :: OP_PUSHDATA(hex"0000000000000000000000000000000000000000000000000000000000000000") :: Nil), TxOut(354 sat, OP_3 :: OP_PUSHDATA(hex"0000000000") :: Nil), + TxOut(0 sat, OP_RETURN :: OP_PUSHDATA(hex"deadbeef") :: Nil), ) def toClosingTx(txOut: Seq[TxOut]): ClosingTx = { 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 278f45290b..562d9ec311 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 8e49654cc3..19e0eecdbf 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, FailureReason, 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..099382197f 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,20 +17,22 @@ 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 import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fsm.Channel -import fr.acinq.eclair.channel.publish.TxPublisher.{PublishFinalTx, PublishTx} +import fr.acinq.eclair.channel.publish.TxPublisher.{PublishFinalTx, PublishTx, SetChannelId} import fr.acinq.eclair.channel.states.ChannelStateTestsBase.PimpTestFSM import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags} +import fr.acinq.eclair.io.Peer +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, ChannelReestablish, ClosingComplete, ClosingSig, ClosingSigned, ClosingTlv, Error, Init, 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 +65,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 +85,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 +483,169 @@ 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.expectMsgType[Warning] + 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.expectMsgType[Warning] + 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] + bob2alice.forward(alice) + val bobTxId1 = alice2blockchain.expectMsgType[PublishFinalTx].tx.txid + assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobTxId1) + alice2bob.expectMsgType[ClosingSig] + alice2bob.forward(bob) + assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == bobTxId1) + assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == bobTxId1) + + // Bob cannot send shutdown while signing is in progress. + val bobScript = Script.write(Script.pay2wpkh(randomKey().publicKey)) + val probe = TestProbe() + bob ! CMD_CLOSE(probe.ref, Some(bobScript), Some(ClosingFeerates(preferred = FeeratePerKw(2500 sat), min = FeeratePerKw(253 sat), max = FeeratePerKw(2500 sat)))) + probe.expectMsgType[RES_FAILURE[CMD_CLOSE, ClosingAlreadyInProgress]] + + // After sending closing_sig to Alice, Bob can update his closing script. + alice2bob.forward(bob, aliceClosingComplete1) + val bobClosingSig1 = bob2alice.expectMsgType[ClosingSig] + val aliceTxId1 = bob2blockchain.expectMsgType[PublishFinalTx].tx.txid + assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceTxId1) + bob ! CMD_CLOSE(probe.ref, Some(bobScript), Some(ClosingFeerates(preferred = FeeratePerKw(2500 sat), min = FeeratePerKw(253 sat), max = FeeratePerKw(2500 sat)))) + val bobShutdown2 = bob2alice.expectMsgType[Shutdown] + assert(bobShutdown2.scriptPubKey == bobScript) + + // If Bob sends shutdown without sending closing_sig first, Alice will ignore it. + bob2alice.forward(alice, bobShutdown2) + alice2bob.expectMsgType[Warning] + alice2bob.expectNoMessage(100 millis) + + // After sending closing_sig, Bob can send his second shutdown. + bob2alice.forward(alice, bobClosingSig1) + assert(alice2blockchain.expectMsgType[PublishFinalTx].tx.txid == aliceTxId1) + assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceTxId1) + bob2alice.forward(alice, bobShutdown2) + + // Alice re-sends shutdown in response to Bob's shutdown, at which point they sign transactions from scratch. + alice2bob.expectMsgType[Shutdown] + alice2bob.forward(bob) + bob2alice.expectMsgType[ClosingComplete] + bob2alice.forward(alice) + val bobTxId2 = alice2blockchain.expectMsgType[PublishFinalTx].tx.txid + assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobTxId2) + alice2bob.expectMsgType[ClosingComplete] + alice2bob.forward(bob) + val aliceTxId2 = bob2blockchain.expectMsgType[PublishFinalTx].tx.txid + assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceTxId2) + alice2bob.expectMsgType[ClosingSig] + alice2bob.forward(bob) + assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == bobTxId2) + assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == bobTxId2) + bob2alice.expectMsgType[ClosingSig] + bob2alice.forward(alice) + assert(alice2blockchain.expectMsgType[PublishFinalTx].tx.txid == aliceTxId2) + assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceTxId2) + } + test("recv WatchFundingSpentTriggered (counterparty's mutual close)") { f => import f._ aliceClose(f) @@ -533,6 +706,97 @@ 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) + bob2alice.expectMsgType[Shutdown] + bob2alice.forward(alice) + alice2bob.expectMsgType[ClosingComplete] + alice2bob.forward(bob) + bob2alice.expectMsgType[ClosingComplete] // ignored + val bobClosingSig = bob2alice.expectMsgType[ClosingSig] + bob2alice.forward(alice, bobClosingSig) + 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,12 +837,106 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike awaitCond(bob.stateName == CLOSING) } - test("recv WatchFundingSpentTriggered (unrecognized commit)") { f => + test("receive INPUT_RECONNECTED", Tag(ChannelStateTestsTags.SimpleClose)) { f => import f._ - bobClose(f) - alice ! WatchFundingSpentTriggered(Transaction(0, Nil, Nil, 0)) - alice2blockchain.expectNoMessage(100 millis) - assert(alice.stateName == NEGOTIATING) + aliceClose(f) + alice2bob.expectMsgType[ClosingComplete] + alice2bob.forward(bob) + val aliceTxId1 = bob2blockchain.expectMsgType[PublishFinalTx].tx.txid + assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceTxId1) + bob2alice.expectMsgType[ClosingComplete] + bob2alice.forward(alice) + val bobTxId1 = alice2blockchain.expectMsgType[PublishFinalTx].tx.txid + assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobTxId1) + alice2bob.expectMsgType[ClosingSig] + alice2bob.forward(bob) + assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == bobTxId1) + assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == bobTxId1) + bob2alice.expectMsgType[ClosingSig] // ignored + + // A disconnection happens before Alice received Bob's closing_sig. + // On reconnection, she retries signing her closing transaction. + alice ! INPUT_DISCONNECTED + bob ! INPUT_DISCONNECTED + awaitCond(alice.stateName == OFFLINE) + awaitCond(bob.stateName == OFFLINE) + val aliceInit = Init(TestConstants.Alice.nodeParams.features.initFeatures()) + val bobInit = Init(TestConstants.Bob.nodeParams.features.initFeatures()) + alice ! INPUT_RECONNECTED(alice2bob.ref, aliceInit, bobInit) + bob ! INPUT_RECONNECTED(bob2alice.ref, bobInit, aliceInit) + alice2bob.expectMsgType[ChannelReestablish] + alice2bob.forward(bob) + bob2alice.expectMsgType[ChannelReestablish] + bob2alice.forward(alice) + alice2bob.expectMsgType[Shutdown] + alice2bob.forward(bob) + bob2alice.expectMsgType[Shutdown] + bob2alice.forward(alice) + alice2bob.expectMsgType[ClosingComplete] + alice2bob.forward(bob) + bob2alice.expectMsgType[ClosingComplete] // ignored + val aliceTxId2 = bob2blockchain.expectMsgType[PublishFinalTx].tx.txid + assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceTxId2) + val bobClosingSig = bob2alice.expectMsgType[ClosingSig] + bob2alice.forward(alice, bobClosingSig) + assert(alice2blockchain.expectMsgType[PublishFinalTx].tx.txid == aliceTxId2) + assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceTxId2) + bob2alice.expectNoMessage(100 millis) + } + + test("receive INPUT_RESTORED", Tag(ChannelStateTestsTags.SimpleClose)) { f => + import f._ + aliceClose(f) + alice2bob.expectMsgType[ClosingComplete] + alice2bob.forward(bob) + val aliceTxId1 = bob2blockchain.expectMsgType[PublishFinalTx].tx.txid + assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceTxId1) + bob2alice.expectMsgType[ClosingComplete] + bob2alice.forward(alice) + val bobTxId1 = alice2blockchain.expectMsgType[PublishFinalTx].tx.txid + assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobTxId1) + alice2bob.expectMsgType[ClosingSig] + alice2bob.forward(bob) + assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == bobTxId1) + assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == bobTxId1) + bob2alice.expectMsgType[ClosingSig] // ignored + val aliceData = alice.underlyingActor.nodeParams.db.channels.getChannel(channelId(alice)).get + val bobData = bob.underlyingActor.nodeParams.db.channels.getChannel(channelId(bob)).get + + // Alice restarts before receiving Bob's closing_sig. + // On reconnection, she retries signing her closing transaction. + // simulate another node restart + alice.setState(WAIT_FOR_INIT_INTERNAL, Nothing) + alice ! INPUT_RESTORED(aliceData) + alice2blockchain.expectMsgType[SetChannelId] + alice2blockchain.expectMsgType[WatchFundingSpent] + bob.setState(WAIT_FOR_INIT_INTERNAL, Nothing) + bob ! INPUT_RESTORED(bobData) + bob2blockchain.expectMsgType[SetChannelId] + bob2blockchain.expectMsgType[WatchFundingSpent] + awaitCond(alice.stateName == OFFLINE && bob.stateName == OFFLINE) + val aliceInit = Init(TestConstants.Alice.nodeParams.features.initFeatures()) + val bobInit = Init(TestConstants.Bob.nodeParams.features.initFeatures()) + alice ! INPUT_RECONNECTED(alice2bob.ref, aliceInit, bobInit) + bob ! INPUT_RECONNECTED(bob2alice.ref, bobInit, aliceInit) + alice2bob.expectMsgType[ChannelReestablish] + alice2bob.forward(bob) + bob2alice.expectMsgType[ChannelReestablish] + bob2alice.forward(alice) + alice2bob.expectMsgType[Shutdown] + alice2bob.forward(bob) + bob2alice.expectMsgType[Shutdown] + bob2alice.forward(alice) + alice2bob.expectMsgType[ClosingComplete] + alice2bob.forward(bob) + bob2alice.expectMsgType[ClosingComplete] // ignored + val aliceTxId2 = bob2blockchain.expectMsgType[PublishFinalTx].tx.txid + assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceTxId2) + val bobClosingSig = bob2alice.expectMsgType[ClosingSig] + bob2alice.forward(alice, bobClosingSig) + assert(alice2blockchain.expectMsgType[PublishFinalTx].tx.txid == aliceTxId2) + assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceTxId2) + bob2alice.expectNoMessage(100 millis) } test("recv Error") { f => @@ -593,4 +951,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) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4Spec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4Spec.scala index ca67359422..6e9007fe02 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4Spec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4Spec.scala @@ -17,7 +17,7 @@ import fr.acinq.eclair.transactions.{CommitmentSpec, Scripts} import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec.normal import fr.acinq.eclair.wire.internal.channel.version4.ChannelCodecs4.Codecs._ import fr.acinq.eclair.wire.internal.channel.version4.ChannelCodecs4.channelDataCodec -import fr.acinq.eclair.wire.protocol.{LiquidityAds, TxSignatures} +import fr.acinq.eclair.wire.protocol.{ClosingComplete, ClosingSig, LiquidityAds, Shutdown, TxSignatures} import fr.acinq.eclair.{BlockHeight, CltvExpiryDelta, Features, MilliSatoshi, MilliSatoshiLong, UInt64, randomBytes32, randomKey} import org.scalatest.funsuite.AnyFunSuite import scodec.bits._ @@ -230,4 +230,26 @@ class ChannelCodecs4Spec extends AnyFunSuite { assert(originCodec.decode(trampolineRelayedBin.bits).require.value == trampolineRelayed) } + test("encode/decode closing negotiation status") { + val channelId = randomBytes32() + val localShutdown = Shutdown(channelId, Script.write(Script.pay2wpkh(randomKey().publicKey))) + val remoteShutdown = Shutdown(channelId, Script.write(Script.pay2wpkh(randomKey().publicKey))) + val closingFeerate = FeeratePerKw(5000 sat) + val waitingForRemoteShutdown = ClosingNegotiation.WaitingForRemoteShutdown(localShutdown, closingFeerate) + val closingComplete = ClosingComplete(channelId, 1500 sat, 0) + val closingSigReceived = ClosingSig(channelId) + val testCases = Seq( + waitingForRemoteShutdown, + ClosingNegotiation.WaitingForConfirmation(localShutdown, remoteShutdown, closingFeerate), + ClosingNegotiation.SigningTransactions(localShutdown, remoteShutdown, closingFeerate, None, None, None), + ClosingNegotiation.SigningTransactions(localShutdown, remoteShutdown, closingFeerate, Some(closingComplete), None, None), + ClosingNegotiation.SigningTransactions(localShutdown, remoteShutdown, closingFeerate, Some(closingComplete), None, Some(closingSigReceived)), + ) + testCases.foreach { status => + val encoded = closingNegotiationCodec.encode(status).require + val decoded = closingNegotiationCodec.decode(encoded).require.value + assert(decoded == waitingForRemoteShutdown) + } + } + } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala index 5f7dbc939d..23acceb03f 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/wire/protocol/LightningMessageCodecsSpec.scala @@ -519,6 +519,31 @@ class LightningMessageCodecsSpec extends AnyFunSuite { } } + test("encode/decode closing messages") { + val channelId = ByteVector32(hex"58a00a6f14e69a2e97b18cf76f755c8551fea9947cf7b6ece9d641013eba5f86") + val sig1 = ByteVector64(hex"01010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101") + val sig2 = ByteVector64(hex"02020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202") + val sig3 = ByteVector64(hex"03030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303") + val testCases = Seq( + hex"0028 58a00a6f14e69a2e97b18cf76f755c8551fea9947cf7b6ece9d641013eba5f86 0000000000000451 00000000" -> ClosingComplete(channelId, 1105 sat, 0), + hex"0028 58a00a6f14e69a2e97b18cf76f755c8551fea9947cf7b6ece9d641013eba5f86 0000000000000451 000c96a8 024001010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101" -> ClosingComplete(channelId, 1105 sat, 825_000, TlvStream(ClosingTlv.NoCloserClosee(sig1))), + hex"0028 58a00a6f14e69a2e97b18cf76f755c8551fea9947cf7b6ece9d641013eba5f86 0000000000000451 00000000 034001010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101" -> ClosingComplete(channelId, 1105 sat, 0, TlvStream(ClosingTlv.CloserAndClosee(sig1))), + hex"0028 58a00a6f14e69a2e97b18cf76f755c8551fea9947cf7b6ece9d641013eba5f86 0000000000000451 00000000 014001010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101 034002020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202" -> ClosingComplete(channelId, 1105 sat, 0, TlvStream(ClosingTlv.CloserNoClosee(sig1), ClosingTlv.CloserAndClosee(sig2))), + hex"0028 58a00a6f14e69a2e97b18cf76f755c8551fea9947cf7b6ece9d641013eba5f86 0000000000000451 00000000 014001010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101 024002020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202 034003030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303" -> ClosingComplete(channelId, 1105 sat, 0, TlvStream(ClosingTlv.CloserNoClosee(sig1), ClosingTlv.NoCloserClosee(sig2), ClosingTlv.CloserAndClosee(sig3))), + hex"0029 58a00a6f14e69a2e97b18cf76f755c8551fea9947cf7b6ece9d641013eba5f86" -> ClosingSig(channelId), + hex"0029 58a00a6f14e69a2e97b18cf76f755c8551fea9947cf7b6ece9d641013eba5f86 024001010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101" -> ClosingSig(channelId, TlvStream(ClosingTlv.NoCloserClosee(sig1))), + hex"0029 58a00a6f14e69a2e97b18cf76f755c8551fea9947cf7b6ece9d641013eba5f86 034001010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101" -> ClosingSig(channelId, TlvStream(ClosingTlv.CloserAndClosee(sig1))), + hex"0029 58a00a6f14e69a2e97b18cf76f755c8551fea9947cf7b6ece9d641013eba5f86 014001010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101 034002020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202" -> ClosingSig(channelId, TlvStream(ClosingTlv.CloserNoClosee(sig1), ClosingTlv.CloserAndClosee(sig2))), + hex"0029 58a00a6f14e69a2e97b18cf76f755c8551fea9947cf7b6ece9d641013eba5f86 014001010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101 024002020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202 034003030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303" -> ClosingSig(channelId, TlvStream(ClosingTlv.CloserNoClosee(sig1), ClosingTlv.NoCloserClosee(sig2), ClosingTlv.CloserAndClosee(sig3))), + ) + for ((encoded, expected) <- testCases) { + val decoded = lightningMessageCodec.decode(encoded.bits).require.value + assert(decoded == expected) + val reEncoded = lightningMessageCodec.encode(expected).require.bytes + assert(reEncoded == encoded) + } + } + test("encode/decode all channel messages") { val unknownTlv = GenericTlv(UInt64(5), ByteVector.fromValidHex("deadbeef")) val msgs = List(