Skip to content

Commit 67aa29f

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 8a9e637 commit 67aa29f

21 files changed

+935
-82
lines changed

docs/release-notes/eclair-vnext.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,17 @@
44

55
## Major changes
66

7-
<insert changes>
7+
### Simplified mutual close
8+
9+
This release includes support for the latest [mutual close protocol](https://github.com/lightning/bolts/pull/1096).
10+
This protocol allows both channel participants to decide exactly how much fees they're willing to pay to close the channel.
11+
Each participant obtains a channel closing transaction where they are paying the fees.
12+
13+
Once closing transactions are broadcast, they can be RBF-ed by calling the `close` RPC again with a higher feerate:
14+
15+
```sh
16+
./eclair-cli close --channelId=<channel_id> --preferredFeerateSatByte=<rbf_feerate>
17+
```
818

919
### Peer storage
1020

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: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ case object WAIT_FOR_DUAL_FUNDING_READY extends ChannelState
7272
case object NORMAL extends ChannelState
7373
case object SHUTDOWN extends ChannelState
7474
case object NEGOTIATING extends ChannelState
75+
case object NEGOTIATING_SIMPLE extends ChannelState
7576
case object CLOSING extends ChannelState
7677
case object CLOSED extends ChannelState
7778
case object OFFLINE extends ChannelState
@@ -653,6 +654,16 @@ final case class DATA_NEGOTIATING(commitments: Commitments,
653654
require(closingTxProposed.nonEmpty, "there must always be a list for the current negotiation")
654655
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")
655656
}
657+
final case class DATA_NEGOTIATING_SIMPLE(commitments: Commitments,
658+
lastClosingFeerate: FeeratePerKw,
659+
localScriptPubKey: ByteVector, remoteScriptPubKey: ByteVector,
660+
// Closing transactions we created, where we pay the fees (unsigned).
661+
proposedClosingTxs: List[ClosingTxs],
662+
// Closing transactions we published: this contains our local transactions for
663+
// which they sent a signature, and their closing transactions that we signed.
664+
publishedClosingTxs: List[ClosingTx]) extends ChannelDataWithCommitments {
665+
def findClosingTx(tx: Transaction): Option[ClosingTx] = publishedClosingTxs.find(_.tx.txid == tx.txid).orElse(proposedClosingTxs.flatMap(_.all).find(_.tx.txid == tx.txid))
666+
}
656667
final case class DATA_CLOSING(commitments: Commitments,
657668
waitingSince: BlockHeight, // how long since we initiated the closing
658669
finalScriptPubKey: ByteVector, // where to send all on-chain funds

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,10 @@ case class FeerateTooDifferent (override val channelId: Byte
116116
case class InvalidAnnouncementSignatures (override val channelId: ByteVector32, annSigs: AnnouncementSignatures) extends ChannelException(channelId, s"invalid announcement signatures: $annSigs")
117117
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")
118118
case class InvalidHtlcSignature (override val channelId: ByteVector32, txId: TxId) extends ChannelException(channelId, s"invalid htlc signature: txId=$txId")
119+
case class CannotGenerateClosingTx (override val channelId: ByteVector32) extends ChannelException(channelId, "failed to generate closing transaction: all outputs are trimmed")
120+
case class MissingCloseSignature (override val channelId: ByteVector32) extends ChannelException(channelId, "closing_complete is missing a signature for a closing transaction including our output")
119121
case class InvalidCloseSignature (override val channelId: ByteVector32, txId: TxId) extends ChannelException(channelId, s"invalid close signature: txId=$txId")
122+
case class InvalidCloseeScript (override val channelId: ByteVector32, received: ByteVector, expected: ByteVector) extends ChannelException(channelId, s"invalid closee script used in closing_complete: our latest script is $expected, you're using $received")
120123
case class InvalidCloseAmountBelowDust (override val channelId: ByteVector32, txId: TxId) extends ChannelException(channelId, s"invalid closing tx: some outputs are below dust: txId=$txId")
121124
case class CommitSigCountMismatch (override val channelId: ByteVector32, expected: Int, actual: Int) extends ChannelException(channelId, s"commit sig count mismatch: expected=$expected actual=$actual")
122125
case class HtlcSigCountMismatch (override val channelId: ByteVector32, expected: Int, actual: Int) extends ChannelException(channelId, s"htlc sig count mismatch: expected=$expected actual=$actual")

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

Lines changed: 91 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
}
@@ -709,6 +710,96 @@ object Helpers {
709710
}
710711
}
711712

713+
/** We are the closer: we sign closing transactions for which we pay the fees. */
714+
def makeSimpleClosingTx(currentBlockHeight: BlockHeight, keyManager: ChannelKeyManager, commitment: FullCommitment, localScriptPubkey: ByteVector, remoteScriptPubkey: ByteVector, feerate: FeeratePerKw): Either[ChannelException, (ClosingTxs, ClosingComplete)] = {
715+
// We must convert the feerate to a fee: we must build dummy transactions to compute their weight.
716+
val closingFee = {
717+
val dummyClosingTxs = Transactions.makeSimpleClosingTxs(commitment.commitInput, commitment.localCommit.spec, SimpleClosingTxFee.PaidByUs(0 sat), currentBlockHeight.toLong, localScriptPubkey, remoteScriptPubkey)
718+
dummyClosingTxs.preferred_opt match {
719+
case Some(dummyTx) =>
720+
val dummySignedTx = Transactions.addSigs(dummyTx, Transactions.PlaceHolderPubKey, Transactions.PlaceHolderPubKey, Transactions.PlaceHolderSig, Transactions.PlaceHolderSig)
721+
SimpleClosingTxFee.PaidByUs(Transactions.weight2fee(feerate, dummySignedTx.tx.weight()))
722+
case None => return Left(CannotGenerateClosingTx(commitment.channelId))
723+
}
724+
}
725+
// Now that we know the fee we're ready to pay, we can create our closing transactions.
726+
val closingTxs = Transactions.makeSimpleClosingTxs(commitment.commitInput, commitment.localCommit.spec, closingFee, currentBlockHeight.toLong, localScriptPubkey, remoteScriptPubkey)
727+
// The actual fee we're paying will be bigger than the one we previously computed if we omit our output.
728+
val actualFee = closingTxs.preferred_opt match {
729+
case Some(closingTx) if closingTx.fee > 0.sat => closingTx.fee
730+
case _ => return Left(CannotGenerateClosingTx(commitment.channelId))
731+
}
732+
val localFundingPubKey = keyManager.fundingPublicKey(commitment.localParams.fundingKeyPath, commitment.fundingTxIndex)
733+
val closingComplete = ClosingComplete(commitment.channelId, localScriptPubkey, remoteScriptPubkey, actualFee, currentBlockHeight.toLong, TlvStream(Set(
734+
closingTxs.localAndRemote_opt.map(tx => ClosingTlv.CloserAndCloseeOutputs(keyManager.sign(tx, localFundingPubKey, TxOwner.Local, commitment.params.commitmentFormat))),
735+
closingTxs.localOnly_opt.map(tx => ClosingTlv.CloserOutputOnly(keyManager.sign(tx, localFundingPubKey, TxOwner.Local, commitment.params.commitmentFormat))),
736+
closingTxs.remoteOnly_opt.map(tx => ClosingTlv.CloseeOutputOnly(keyManager.sign(tx, localFundingPubKey, TxOwner.Local, commitment.params.commitmentFormat))),
737+
).flatten[ClosingTlv]))
738+
Right(closingTxs, closingComplete)
739+
}
740+
741+
/**
742+
* We are the closee: we choose one of the closer's transactions and sign it back.
743+
*
744+
* Callers should ignore failures: since the protocol is fully asynchronous, failures here simply mean that they
745+
* are not using our latest script (race condition between our closing_complete and theirs).
746+
*/
747+
def signSimpleClosingTx(keyManager: ChannelKeyManager, commitment: FullCommitment, localScriptPubkey: ByteVector, remoteScriptPubkey: ByteVector, closingComplete: ClosingComplete): Either[ChannelException, (ClosingTx, ClosingSig)] = {
748+
val closingFee = SimpleClosingTxFee.PaidByThem(closingComplete.fees)
749+
val closingTxs = Transactions.makeSimpleClosingTxs(commitment.commitInput, commitment.localCommit.spec, closingFee, closingComplete.lockTime, localScriptPubkey, remoteScriptPubkey)
750+
// If our output isn't dust, they must provide a signature for a transaction that includes it.
751+
// Note that we're the closee, so we look for signatures including the closee output.
752+
(closingTxs.localAndRemote_opt, closingTxs.localOnly_opt) match {
753+
case (Some(_), Some(_)) if closingComplete.closerAndCloseeOutputsSig_opt.isEmpty && closingComplete.closeeOutputOnlySig_opt.isEmpty => return Left(MissingCloseSignature(commitment.channelId))
754+
case (Some(_), None) if closingComplete.closerAndCloseeOutputsSig_opt.isEmpty => return Left(MissingCloseSignature(commitment.channelId))
755+
case (None, Some(_)) if closingComplete.closeeOutputOnlySig_opt.isEmpty => return Left(MissingCloseSignature(commitment.channelId))
756+
case _ => ()
757+
}
758+
// We choose the closing signature that matches our preferred closing transaction.
759+
val closingTxsWithSigs = Seq(
760+
closingComplete.closerAndCloseeOutputsSig_opt.flatMap(remoteSig => closingTxs.localAndRemote_opt.map(tx => (tx, remoteSig, localSig => ClosingTlv.CloserAndCloseeOutputs(localSig)))),
761+
closingComplete.closeeOutputOnlySig_opt.flatMap(remoteSig => closingTxs.localOnly_opt.map(tx => (tx, remoteSig, localSig => ClosingTlv.CloseeOutputOnly(localSig)))),
762+
closingComplete.closerOutputOnlySig_opt.flatMap(remoteSig => closingTxs.remoteOnly_opt.map(tx => (tx, remoteSig, localSig => ClosingTlv.CloserOutputOnly(localSig)))),
763+
).flatten
764+
closingTxsWithSigs.headOption match {
765+
case Some((closingTx, remoteSig, sigToTlv)) =>
766+
val localFundingPubKey = keyManager.fundingPublicKey(commitment.localParams.fundingKeyPath, commitment.fundingTxIndex)
767+
val localSig = keyManager.sign(closingTx, localFundingPubKey, TxOwner.Local, commitment.params.commitmentFormat)
768+
val signedClosingTx = Transactions.addSigs(closingTx, localFundingPubKey.publicKey, commitment.remoteFundingPubKey, localSig, remoteSig)
769+
Transactions.checkSpendable(signedClosingTx) match {
770+
case Failure(_) => Left(InvalidCloseSignature(commitment.channelId, signedClosingTx.tx.txid))
771+
case Success(_) => Right(signedClosingTx, ClosingSig(commitment.channelId, remoteScriptPubkey, localScriptPubkey, closingComplete.fees, closingComplete.lockTime, TlvStream(sigToTlv(localSig))))
772+
}
773+
case None => Left(MissingCloseSignature(commitment.channelId))
774+
}
775+
}
776+
777+
/**
778+
* We are the closer: they sent us their signature so we should now have a fully signed closing transaction.
779+
*
780+
* Callers should ignore failures: since the protocol is fully asynchronous, failures here simply mean that we
781+
* sent another closing_complete before receiving their closing_sig, which is now obsolete: we ignore it and wait
782+
* for their next closing_sig that will match our latest closing_complete.
783+
*/
784+
def receiveSimpleClosingSig(keyManager: ChannelKeyManager, commitment: FullCommitment, closingTxs: ClosingTxs, closingSig: ClosingSig): Either[ChannelException, ClosingTx] = {
785+
val closingTxsWithSig = Seq(
786+
closingSig.closerAndCloseeOutputsSig_opt.flatMap(sig => closingTxs.localAndRemote_opt.map(tx => (tx, sig))),
787+
closingSig.closerOutputOnlySig_opt.flatMap(sig => closingTxs.localOnly_opt.map(tx => (tx, sig))),
788+
closingSig.closeeOutputOnlySig_opt.flatMap(sig => closingTxs.remoteOnly_opt.map(tx => (tx, sig))),
789+
).flatten
790+
closingTxsWithSig.headOption match {
791+
case Some((closingTx, remoteSig)) =>
792+
val localFundingPubKey = keyManager.fundingPublicKey(commitment.localParams.fundingKeyPath, commitment.fundingTxIndex)
793+
val localSig = keyManager.sign(closingTx, localFundingPubKey, TxOwner.Local, commitment.params.commitmentFormat)
794+
val signedClosingTx = Transactions.addSigs(closingTx, localFundingPubKey.publicKey, commitment.remoteFundingPubKey, localSig, remoteSig)
795+
Transactions.checkSpendable(signedClosingTx) match {
796+
case Failure(_) => Left(InvalidCloseSignature(commitment.channelId, signedClosingTx.tx.txid))
797+
case Success(_) => Right(signedClosingTx)
798+
}
799+
case None => Left(MissingCloseSignature(commitment.channelId))
800+
}
801+
}
802+
712803
/**
713804
* Check that all closing outputs are above bitcoin's dust limit for their script type, otherwise there is a risk
714805
* that the closing transaction will not be relayed to miners' mempool and will not confirm.

0 commit comments

Comments
 (0)