Skip to content

Commit f0571b2

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 b2314cd commit f0571b2

File tree

21 files changed

+815
-86
lines changed

21 files changed

+815
-86
lines changed

docs/release-notes/eclair-vnext.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,18 @@ Every node advertizes the rates at which they sell their liquidity, and buyers c
1313
The liquidity ads specification is still under review and will likely change.
1414
This feature isn't meant to be used on mainnet yet and is thus disabled by default.
1515

16+
### Simplified mutual close
17+
18+
This release includes support for the latest [mutual close protocol](https://github.com/lightning/bolts/pull/1096).
19+
This protocol allows both channel participants to decide exactly how much fees they're willing to pay to close the channel.
20+
Each participant obtains a channel closing transaction where they are paying the fees.
21+
22+
Once closing transactions are broadcast, they can be RBF-ed by calling the `close` RPC again with a higher feerate:
23+
24+
```sh
25+
./eclair-cli close --channelId=<channel_id> --preferredFeerateSatByte=<rbf_feerate>
26+
```
27+
1628
### Update minimal version of Bitcoin Core
1729

1830
With this release, eclair requires using Bitcoin Core 27.1.

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
@@ -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
@@ -625,6 +626,15 @@ final case class DATA_NEGOTIATING(commitments: Commitments,
625626
require(closingTxProposed.nonEmpty, "there must always be a list for the current negotiation")
626627
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")
627628
}
629+
final case class DATA_NEGOTIATING_SIMPLE(commitments: Commitments,
630+
localShutdown: Shutdown, remoteShutdown: Shutdown,
631+
// Closing transactions we created, where we pay the fees (unsigned).
632+
proposedClosingTxs: List[ClosingTxs],
633+
// Closing transactions we published: this contains our local transactions for
634+
// which they sent a signature, and their closing transactions that we signed.
635+
publishedClosingTxs: List[ClosingTx]) extends ChannelDataWithCommitments {
636+
def findClosingTx(tx: Transaction): Option[ClosingTx] = publishedClosingTxs.find(_.tx.txid == tx.txid).orElse(proposedClosingTxs.flatMap(_.all).find(_.tx.txid == tx.txid))
637+
}
628638
final case class DATA_CLOSING(commitments: Commitments,
629639
waitingSince: BlockHeight, // how long since we initiated the closing
630640
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
@@ -112,6 +112,8 @@ case class FeerateTooDifferent (override val channelId: Byte
112112
case class InvalidAnnouncementSignatures (override val channelId: ByteVector32, annSigs: AnnouncementSignatures) extends ChannelException(channelId, s"invalid announcement signatures: $annSigs")
113113
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")
114114
case class InvalidHtlcSignature (override val channelId: ByteVector32, txId: TxId) extends ChannelException(channelId, s"invalid htlc signature: txId=$txId")
115+
case class CannotGenerateClosingTx (override val channelId: ByteVector32) extends ChannelException(channelId, "failed to generate closing transaction: all outputs are trimmed")
116+
case class MissingCloseSignature (override val channelId: ByteVector32) extends ChannelException(channelId, "closing_complete is missing a signature for a closing transaction including our output")
115117
case class InvalidCloseSignature (override val channelId: ByteVector32, txId: TxId) extends ChannelException(channelId, s"invalid close signature: txId=$txId")
116118
case class InvalidCloseAmountBelowDust (override val channelId: ByteVector32, txId: TxId) extends ChannelException(channelId, s"invalid closing tx: some outputs are below dust: txId=$txId")
117119
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: 90 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,95 @@ 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+
require(isValidFinalScriptPubkey(localScriptPubkey, allowAnySegwit = true, allowOpReturn = true), "invalid localScriptPubkey")
716+
require(isValidFinalScriptPubkey(remoteScriptPubkey, allowAnySegwit = true, allowOpReturn = true), "invalid remoteScriptPubkey")
717+
// We must convert the feerate to a fee: we must build dummy transactions to compute their weight.
718+
val closingFee = {
719+
val dummyClosingTxs = Transactions.makeSimpleClosingTxs(commitment.commitInput, commitment.localCommit.spec, SimpleClosingTxFee.PaidByUs(0 sat), currentBlockHeight.toLong, localScriptPubkey, remoteScriptPubkey)
720+
dummyClosingTxs.preferred_opt match {
721+
case Some(dummyTx) =>
722+
val dummySignedTx = Transactions.addSigs(dummyTx, Transactions.PlaceHolderPubKey, Transactions.PlaceHolderPubKey, Transactions.PlaceHolderSig, Transactions.PlaceHolderSig)
723+
SimpleClosingTxFee.PaidByUs(Transactions.weight2fee(feerate, dummySignedTx.tx.weight()))
724+
case None => return Left(CannotGenerateClosingTx(commitment.channelId))
725+
}
726+
}
727+
// Now that we know the fee we're ready to pay, we can create our closing transactions.
728+
val closingTxs = Transactions.makeSimpleClosingTxs(commitment.commitInput, commitment.localCommit.spec, closingFee, currentBlockHeight.toLong, localScriptPubkey, remoteScriptPubkey)
729+
// The actual fee we're paying will be bigger than the one we previously computed if we omit our output.
730+
val actualFee = closingTxs.preferred_opt match {
731+
case Some(closingTx) if closingTx.fee > 0.sat => closingTx.fee
732+
case _ => return Left(CannotGenerateClosingTx(commitment.channelId))
733+
}
734+
val localFundingPubKey = keyManager.fundingPublicKey(commitment.localParams.fundingKeyPath, commitment.fundingTxIndex)
735+
val closingComplete = ClosingComplete(commitment.channelId, actualFee, currentBlockHeight.toLong, TlvStream(Set(
736+
closingTxs.localAndRemote_opt.map(tx => ClosingTlv.CloserAndClosee(keyManager.sign(tx, localFundingPubKey, TxOwner.Local, commitment.params.commitmentFormat))),
737+
closingTxs.localOnly_opt.map(tx => ClosingTlv.CloserNoClosee(keyManager.sign(tx, localFundingPubKey, TxOwner.Local, commitment.params.commitmentFormat))),
738+
closingTxs.remoteOnly_opt.map(tx => ClosingTlv.NoCloserClosee(keyManager.sign(tx, localFundingPubKey, TxOwner.Local, commitment.params.commitmentFormat))),
739+
).flatten[ClosingTlv]))
740+
Right(closingTxs, closingComplete)
741+
}
742+
743+
/**
744+
* We are the closee: we choose one of the closer's transactions and sign it back.
745+
* Callers should ignore failures: since the protocol is fully asynchronous, failures here simply mean that the
746+
* closing_complete doesn't match the latest state of the closing negotiation (someone changed their script).
747+
*/
748+
def signSimpleClosingTx(keyManager: ChannelKeyManager, commitment: FullCommitment, localScriptPubkey: ByteVector, remoteScriptPubkey: ByteVector, closingComplete: ClosingComplete): Either[ChannelException, (ClosingTx, ClosingSig)] = {
749+
val closingFee = SimpleClosingTxFee.PaidByThem(closingComplete.fees)
750+
val closingTxs = Transactions.makeSimpleClosingTxs(commitment.commitInput, commitment.localCommit.spec, closingFee, closingComplete.lockTime, localScriptPubkey, remoteScriptPubkey)
751+
// If our output isn't dust, they must provide a signature for a transaction that includes it.
752+
// Note that we're the closee, so we look for signatures including the closee output.
753+
(closingTxs.localAndRemote_opt, closingTxs.localOnly_opt) match {
754+
case (Some(_), Some(_)) if closingComplete.closerAndCloseeSig_opt.isEmpty && closingComplete.noCloserCloseeSig_opt.isEmpty => return Left(MissingCloseSignature(commitment.channelId))
755+
case (Some(_), None) if closingComplete.closerAndCloseeSig_opt.isEmpty => return Left(MissingCloseSignature(commitment.channelId))
756+
case (None, Some(_)) if closingComplete.noCloserCloseeSig_opt.isEmpty => return Left(MissingCloseSignature(commitment.channelId))
757+
case _ => ()
758+
}
759+
// We choose the closing signature that matches our preferred closing transaction.
760+
val closingTxsWithSigs = Seq(
761+
closingComplete.closerAndCloseeSig_opt.flatMap(remoteSig => closingTxs.localAndRemote_opt.map(tx => (tx, remoteSig, localSig => ClosingTlv.CloserAndClosee(localSig)))),
762+
closingComplete.noCloserCloseeSig_opt.flatMap(remoteSig => closingTxs.localOnly_opt.map(tx => (tx, remoteSig, localSig => ClosingTlv.NoCloserClosee(localSig)))),
763+
closingComplete.closerNoCloseeSig_opt.flatMap(remoteSig => closingTxs.remoteOnly_opt.map(tx => (tx, remoteSig, localSig => ClosingTlv.CloserNoClosee(localSig)))),
764+
).flatten
765+
closingTxsWithSigs.headOption match {
766+
case Some((closingTx, remoteSig, sigToTlv)) =>
767+
val localFundingPubKey = keyManager.fundingPublicKey(commitment.localParams.fundingKeyPath, commitment.fundingTxIndex)
768+
val localSig = keyManager.sign(closingTx, localFundingPubKey, TxOwner.Local, commitment.params.commitmentFormat)
769+
val signedClosingTx = Transactions.addSigs(closingTx, localFundingPubKey.publicKey, commitment.remoteFundingPubKey, localSig, remoteSig)
770+
Transactions.checkSpendable(signedClosingTx) match {
771+
case Failure(_) => Left(InvalidCloseSignature(commitment.channelId, signedClosingTx.tx.txid))
772+
case Success(_) => Right(signedClosingTx, ClosingSig(commitment.channelId, TlvStream(sigToTlv(localSig))))
773+
}
774+
case None => Left(MissingCloseSignature(commitment.channelId))
775+
}
776+
}
777+
778+
/**
779+
* We are the closer: they sent us their signature so we should now have a fully signed closing transaction.
780+
* Callers should ignore failures: since the protocol is fully asynchronous, failures here simply mean that the
781+
* closing_sig doesn't match the latest state of the closing negotiation (someone changed their script).
782+
*/
783+
def receiveSimpleClosingSig(keyManager: ChannelKeyManager, commitment: FullCommitment, closingTxs: ClosingTxs, closingSig: ClosingSig): Either[ChannelException, ClosingTx] = {
784+
val closingTxsWithSig = Seq(
785+
closingSig.closerAndCloseeSig_opt.flatMap(sig => closingTxs.localAndRemote_opt.map(tx => (tx, sig))),
786+
closingSig.closerNoCloseeSig_opt.flatMap(sig => closingTxs.localOnly_opt.map(tx => (tx, sig))),
787+
closingSig.noCloserCloseeSig_opt.flatMap(sig => closingTxs.remoteOnly_opt.map(tx => (tx, sig))),
788+
).flatten
789+
closingTxsWithSig.headOption match {
790+
case Some((closingTx, remoteSig)) =>
791+
val localFundingPubKey = keyManager.fundingPublicKey(commitment.localParams.fundingKeyPath, commitment.fundingTxIndex)
792+
val localSig = keyManager.sign(closingTx, localFundingPubKey, TxOwner.Local, commitment.params.commitmentFormat)
793+
val signedClosingTx = Transactions.addSigs(closingTx, localFundingPubKey.publicKey, commitment.remoteFundingPubKey, localSig, remoteSig)
794+
Transactions.checkSpendable(signedClosingTx) match {
795+
case Failure(_) => Left(InvalidCloseSignature(commitment.channelId, signedClosingTx.tx.txid))
796+
case Success(_) => Right(signedClosingTx)
797+
}
798+
case None => Left(MissingCloseSignature(commitment.channelId))
799+
}
800+
}
801+
712802
/**
713803
* Check that all closing outputs are above bitcoin's dust limit for their script type, otherwise there is a risk
714804
* that the closing transaction will not be relayed to miners' mempool and will not confirm.

0 commit comments

Comments
 (0)