Skip to content

Commit e05d16e

Browse files
committed
Implement the option_simple_close protocol
We introduce a new `NEGOTIATING_SIMPLE` state where we exchange the `closing_complete` and `closing_sig` messages, and allow RBF-ing previous transactions and updating our closing script. We stay in that state until one of the transactions confirms, or a force close is detected. This is important to ensure we're able to correctly reconnect and negotiate RBF candidates. We keep this separate from the previous NEGOTIATING state to make it easier to remove support for the older mutual close protocols once we're confident the network has been upgraded.
1 parent 12d717b commit e05d16e

File tree

18 files changed

+745
-86
lines changed

18 files changed

+745
-86
lines changed

eclair-core/src/main/scala/fr/acinq/eclair/balance/CheckBalance.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ object CheckBalance {
213213
case (r, d: DATA_NORMAL) => r.modify(_.normal).using(updateMainAndHtlcBalance(d.commitments, knownPreimages))
214214
case (r, d: DATA_SHUTDOWN) => r.modify(_.shutdown).using(updateMainAndHtlcBalance(d.commitments, knownPreimages))
215215
case (r, d: DATA_NEGOTIATING) => r.modify(_.negotiating).using(updateMainBalance(d.commitments.latest.localCommit))
216+
case (r, d: DATA_NEGOTIATING_SIMPLE) => r.modify(_.negotiating).using(updateMainBalance(d.commitments.latest.localCommit))
216217
case (r, d: DATA_CLOSING) =>
217218
Closing.isClosingTypeAlreadyKnown(d) match {
218219
case None if d.mutualClosePublished.nonEmpty && d.localCommitPublished.isEmpty && d.remoteCommitPublished.isEmpty && d.nextRemoteCommitPublished.isEmpty && d.revokedCommitPublished.isEmpty =>

eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ case object WAIT_FOR_DUAL_FUNDING_READY extends ChannelState
7373
case object NORMAL extends ChannelState
7474
case object SHUTDOWN extends ChannelState
7575
case object NEGOTIATING extends ChannelState
76+
case object NEGOTIATING_SIMPLE extends ChannelState
7677
case object CLOSING extends ChannelState
7778
case object CLOSED extends ChannelState
7879
case object OFFLINE extends ChannelState
@@ -602,6 +603,15 @@ final case class DATA_NEGOTIATING(commitments: Commitments,
602603
require(closingTxProposed.nonEmpty, "there must always be a list for the current negotiation")
603604
require(!commitments.params.localParams.isInitiator || closingTxProposed.forall(_.nonEmpty), "initiator must have at least one closing signature for every negotiation attempt because it initiates the closing")
604605
}
606+
final case class DATA_NEGOTIATING_SIMPLE(commitments: Commitments,
607+
localShutdown: Shutdown, remoteShutdown: Shutdown,
608+
// Closing transactions we created, where we pay the fees (unsigned).
609+
proposedClosingTxs: List[ClosingTxs],
610+
// Closing transactions we published: this contains our local transactions for
611+
// which they sent a signature, and their closing transactions that we signed.
612+
publishedClosingTxs: List[ClosingTx]) extends ChannelDataWithCommitments {
613+
def findClosingTx(tx: Transaction): Option[ClosingTx] = publishedClosingTxs.find(_.tx.txid == tx.txid).orElse(proposedClosingTxs.flatMap(_.all).find(_.tx.txid == tx.txid))
614+
}
605615
final case class DATA_CLOSING(commitments: Commitments,
606616
waitingSince: BlockHeight, // how long since we initiated the closing
607617
finalScriptPubKey: ByteVector, // where to send all on-chain funds

eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelExceptions.scala

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ case class FeerateTooDifferent (override val channelId: Byte
107107
case class InvalidAnnouncementSignatures (override val channelId: ByteVector32, annSigs: AnnouncementSignatures) extends ChannelException(channelId, s"invalid announcement signatures: $annSigs")
108108
case class InvalidCommitmentSignature (override val channelId: ByteVector32, fundingTxId: ByteVector32, fundingTxIndex: Long, unsignedCommitTx: Transaction) extends ChannelException(channelId, s"invalid commitment signature: fundingTxId=$fundingTxId fundingTxIndex=$fundingTxIndex commitTxId=${unsignedCommitTx.txid} commitTx=$unsignedCommitTx")
109109
case class InvalidHtlcSignature (override val channelId: ByteVector32, txId: ByteVector32) extends ChannelException(channelId, s"invalid htlc signature: txId=$txId")
110+
case class CannotGenerateClosingTx (override val channelId: ByteVector32) extends ChannelException(channelId, "failed to generate closing transaction: all outputs are trimmed")
111+
case class MissingCloseSignature (override val channelId: ByteVector32) extends ChannelException(channelId, "closing_complete is missing a signature for a closing transaction including our output")
110112
case class InvalidCloseSignature (override val channelId: ByteVector32, txId: ByteVector32) extends ChannelException(channelId, s"invalid close signature: txId=$txId")
111113
case class InvalidCloseAmountBelowDust (override val channelId: ByteVector32, txId: ByteVector32) extends ChannelException(channelId, s"invalid closing tx: some outputs are below dust: txId=$txId")
112114
case class CommitSigCountMismatch (override val channelId: ByteVector32, expected: Int, actual: Int) extends ChannelException(channelId, s"commit sig count mismatch: expected=$expected actual=$actual")

eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ object Helpers {
5959
case d: DATA_NORMAL => d.modify(_.commitments.params).using(_.updateFeatures(localInit, remoteInit))
6060
case d: DATA_SHUTDOWN => d.modify(_.commitments.params).using(_.updateFeatures(localInit, remoteInit))
6161
case d: DATA_NEGOTIATING => d.modify(_.commitments.params).using(_.updateFeatures(localInit, remoteInit))
62+
case d: DATA_NEGOTIATING_SIMPLE => d.modify(_.commitments.params).using(_.updateFeatures(localInit, remoteInit))
6263
case d: DATA_CLOSING => d.modify(_.commitments.params).using(_.updateFeatures(localInit, remoteInit))
6364
case d: DATA_WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT => d.modify(_.commitments.params).using(_.updateFeatures(localInit, remoteInit))
6465
}
@@ -688,6 +689,93 @@ object Helpers {
688689
}
689690
}
690691

692+
/** We are the closer: we sign closing transactions for which we pay the fees. */
693+
def makeSimpleClosingTx(keyManager: ChannelKeyManager, commitment: FullCommitment, localScriptPubkey: ByteVector, remoteScriptPubkey: ByteVector, feerate: FeeratePerKw): Either[ChannelException, (ClosingTxs, ClosingComplete)] = {
694+
require(isValidFinalScriptPubkey(localScriptPubkey, allowAnySegwit = true, allowOpReturn = true), "invalid localScriptPubkey")
695+
require(isValidFinalScriptPubkey(remoteScriptPubkey, allowAnySegwit = true, allowOpReturn = true), "invalid remoteScriptPubkey")
696+
val closingFee = {
697+
val dummyClosingTxs = Transactions.makeSimpleClosingTxs(commitment.commitInput, commitment.localCommit.spec, SimpleClosingTxFee.PaidByUs(0 sat), localScriptPubkey, remoteScriptPubkey)
698+
dummyClosingTxs.preferred_opt match {
699+
case Some(dummyTx) =>
700+
val dummySignedTx = Transactions.addSigs(dummyTx, Transactions.PlaceHolderPubKey, Transactions.PlaceHolderPubKey, Transactions.PlaceHolderSig, Transactions.PlaceHolderSig)
701+
SimpleClosingTxFee.PaidByUs(Transactions.weight2fee(feerate, dummySignedTx.tx.weight()))
702+
case None => return Left(CannotGenerateClosingTx(commitment.channelId))
703+
}
704+
}
705+
val closingTxs = Transactions.makeSimpleClosingTxs(commitment.commitInput, commitment.localCommit.spec, closingFee, localScriptPubkey, remoteScriptPubkey)
706+
// The actual fee we're paying will be bigger than the one we previously computed if we omit our output.
707+
val actualFee = closingTxs.preferred_opt match {
708+
case Some(closingTx) if closingTx.fee > 0.sat => closingTx.fee
709+
case _ => return Left(CannotGenerateClosingTx(commitment.channelId))
710+
}
711+
val localFundingPubKey = keyManager.fundingPublicKey(commitment.localParams.fundingKeyPath, commitment.fundingTxIndex)
712+
val closingComplete = ClosingComplete(commitment.channelId, actualFee, TlvStream(Set(
713+
closingTxs.localAndRemote_opt.map(tx => ClosingTlv.CloserAndClosee(keyManager.sign(tx, localFundingPubKey, TxOwner.Local, commitment.params.commitmentFormat))),
714+
closingTxs.localOnly_opt.map(tx => ClosingTlv.CloserNoClosee(keyManager.sign(tx, localFundingPubKey, TxOwner.Local, commitment.params.commitmentFormat))),
715+
closingTxs.remoteOnly_opt.map(tx => ClosingTlv.NoCloserClosee(keyManager.sign(tx, localFundingPubKey, TxOwner.Local, commitment.params.commitmentFormat))),
716+
).flatten[ClosingTlv]))
717+
Right(closingTxs, closingComplete)
718+
}
719+
720+
/**
721+
* We are the closee: we choose one of the closer's transactions and sign it back.
722+
* Callers should ignore failures: since the protocol is fully asynchronous, failures here simply mean that the
723+
* closing_complete doesn't match the latest state of the closing negotiation (someone changed their script).
724+
*/
725+
def signSimpleClosingTx(keyManager: ChannelKeyManager, commitment: FullCommitment, localScriptPubkey: ByteVector, remoteScriptPubkey: ByteVector, closingComplete: ClosingComplete): Either[ChannelException, (ClosingTx, ClosingSig)] = {
726+
val closingFee = SimpleClosingTxFee.PaidByThem(closingComplete.fees)
727+
val closingTxs = Transactions.makeSimpleClosingTxs(commitment.commitInput, commitment.localCommit.spec, closingFee, localScriptPubkey, remoteScriptPubkey)
728+
// If our output isn't dust, they must provide a signature for a transaction that includes it.
729+
// Note that we're the closee, so we look for signatures including the closee output.
730+
(closingTxs.localAndRemote_opt, closingTxs.localOnly_opt) match {
731+
case (Some(_), Some(_)) if closingComplete.closerAndCloseeSig_opt.isEmpty && closingComplete.noCloserCloseeSig_opt.isEmpty => return Left(MissingCloseSignature(commitment.channelId))
732+
case (Some(_), None) if closingComplete.closerAndCloseeSig_opt.isEmpty => return Left(MissingCloseSignature(commitment.channelId))
733+
case (None, Some(_)) if closingComplete.noCloserCloseeSig_opt.isEmpty => return Left(MissingCloseSignature(commitment.channelId))
734+
case _ => ()
735+
}
736+
// We choose the closing signature that matches our preferred closing transaction.
737+
val closingTxsWithSigs = Seq(
738+
closingComplete.closerAndCloseeSig_opt.flatMap(remoteSig => closingTxs.localAndRemote_opt.map(tx => (tx, remoteSig, localSig => ClosingTlv.CloserAndClosee(localSig)))),
739+
closingComplete.noCloserCloseeSig_opt.flatMap(remoteSig => closingTxs.localOnly_opt.map(tx => (tx, remoteSig, localSig => ClosingTlv.NoCloserClosee(localSig)))),
740+
closingComplete.closerNoCloseeSig_opt.flatMap(remoteSig => closingTxs.remoteOnly_opt.map(tx => (tx, remoteSig, localSig => ClosingTlv.CloserNoClosee(localSig)))),
741+
).flatten
742+
closingTxsWithSigs.headOption match {
743+
case Some((closingTx, remoteSig, sigToTlv)) =>
744+
val localFundingPubKey = keyManager.fundingPublicKey(commitment.localParams.fundingKeyPath, commitment.fundingTxIndex)
745+
val localSig = keyManager.sign(closingTx, localFundingPubKey, TxOwner.Local, commitment.params.commitmentFormat)
746+
val signedClosingTx = Transactions.addSigs(closingTx, localFundingPubKey.publicKey, commitment.remoteFundingPubKey, localSig, remoteSig)
747+
Transactions.checkSpendable(signedClosingTx) match {
748+
case Failure(_) => Left(InvalidCloseSignature(commitment.channelId, signedClosingTx.tx.txid))
749+
case Success(_) => Right(signedClosingTx, ClosingSig(commitment.channelId, TlvStream(sigToTlv(localSig))))
750+
}
751+
case None => Left(MissingCloseSignature(commitment.channelId))
752+
}
753+
}
754+
755+
/**
756+
* We are the closer: they sent us their signature so we should now have a fully signed closing transaction.
757+
* Callers should ignore failures: since the protocol is fully asynchronous, failures here simply mean that the
758+
* closing_sig doesn't match the latest state of the closing negotiation (someone changed their script).
759+
*/
760+
def receiveSimpleClosingSig(keyManager: ChannelKeyManager, commitment: FullCommitment, closingTxs: ClosingTxs, closingSig: ClosingSig): Either[ChannelException, ClosingTx] = {
761+
val closingTxsWithSig = Seq(
762+
closingSig.closerAndCloseeSig_opt.flatMap(sig => closingTxs.localAndRemote_opt.map(tx => (tx, sig))),
763+
closingSig.closerNoCloseeSig_opt.flatMap(sig => closingTxs.localOnly_opt.map(tx => (tx, sig))),
764+
closingSig.noCloserCloseeSig_opt.flatMap(sig => closingTxs.remoteOnly_opt.map(tx => (tx, sig))),
765+
).flatten
766+
closingTxsWithSig.headOption match {
767+
case Some((closingTx, remoteSig)) =>
768+
val localFundingPubKey = keyManager.fundingPublicKey(commitment.localParams.fundingKeyPath, commitment.fundingTxIndex)
769+
val localSig = keyManager.sign(closingTx, localFundingPubKey, TxOwner.Local, commitment.params.commitmentFormat)
770+
val signedClosingTx = Transactions.addSigs(closingTx, localFundingPubKey.publicKey, commitment.remoteFundingPubKey, localSig, remoteSig)
771+
Transactions.checkSpendable(signedClosingTx) match {
772+
case Failure(_) => Left(InvalidCloseSignature(commitment.channelId, signedClosingTx.tx.txid))
773+
case Success(_) => Right(signedClosingTx)
774+
}
775+
case None => Left(MissingCloseSignature(commitment.channelId))
776+
}
777+
}
778+
691779
/**
692780
* Check that all closing outputs are above bitcoin's dust limit for their script type, otherwise there is a risk
693781
* that the closing transaction will not be relayed to miners' mempool and will not confirm.

0 commit comments

Comments
 (0)