Skip to content

Commit 3aac8da

Browse files
authored
Implement option_simple_close (#2967)
We add support for the latest channel closing protocol described in lightning/bolts#1205. This is a prerequisite for taproot channels. 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 372222d commit 3aac8da

File tree

50 files changed

+1361
-308
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+1361
-308
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/1205).
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/resources/reference.conf

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ eclair {
8484
// node that you trust using override-init-features (see below).
8585
option_zeroconf = disabled
8686
keysend = disabled
87+
option_simple_close=optional
8788
trampoline_payment_prototype = disabled
8889
async_payment_prototype = disabled
8990
on_the_fly_funding = disabled
@@ -132,8 +133,7 @@ eclair {
132133

133134
to-remote-delay-blocks = 720 // number of blocks that the other node's to-self outputs must be delayed (720 ~ 5 days)
134135
max-to-local-delay-blocks = 2016 // maximum number of blocks that we are ready to accept for our own delayed outputs (2016 ~ 2 weeks)
135-
min-depth-funding-blocks = 6 // minimum number of confirmations for funding transactions
136-
min-depth-closing-blocks = 3 // minimum number of confirmations for closing transactions
136+
min-depth-blocks = 6 // minimum number of confirmations for channel transactions, which we will additionally scale based on the amount at stake
137137
expiry-delta-blocks = 144
138138
max-expiry-delta-blocks = 2016 // we won't forward HTLCs with timeouts greater than this delta
139139
// When we receive the preimage for an HTLC and want to fulfill it but the upstream peer stops responding, we want to

eclair-core/src/main/scala/fr/acinq/eclair/Features.scala

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,11 @@ object Features {
305305
val mandatory = 54
306306
}
307307

308+
case object SimpleClose extends Feature with InitFeature with NodeFeature {
309+
val rfcName = "option_simple_close"
310+
val mandatory = 60
311+
}
312+
308313
/** This feature bit indicates that the node is a mobile wallet that can be woken up via push notifications. */
309314
case object WakeUpNotificationClient extends Feature with InitFeature {
310315
val rfcName = "wake_up_notification_client"
@@ -375,6 +380,7 @@ object Features {
375380
PaymentMetadata,
376381
ZeroConf,
377382
KeySend,
383+
SimpleClose,
378384
WakeUpNotificationClient,
379385
TrampolinePaymentPrototype,
380386
AsyncPaymentPrototype,
@@ -393,6 +399,7 @@ object Features {
393399
RouteBlinding -> (VariableLengthOnion :: Nil),
394400
TrampolinePaymentPrototype -> (PaymentSecret :: Nil),
395401
KeySend -> (VariableLengthOnion :: Nil),
402+
SimpleClose -> (ShutdownAnySegwit :: Nil),
396403
AsyncPaymentPrototype -> (TrampolinePaymentPrototype :: Nil),
397404
OnTheFlyFunding -> (SplicePrototype :: Nil),
398405
FundingFeeCredit -> (OnTheFlyFunding :: Nil)

eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,7 @@ object NodeParams extends Logging {
319319
"on-chain-fees.target-blocks.safe-utxos-threshold" -> "on-chain-fees.safe-utxos-threshold",
320320
"on-chain-fees.target-blocks" -> "on-chain-fees.confirmation-priority",
321321
// v0.12.0
322-
"channel.mindepth-blocks" -> "channel.min-depth-funding-blocks",
322+
"channel.mindepth-blocks" -> "channel.min-depth-blocks",
323323
"sync-whitelist" -> "router.sync.whitelist",
324324
)
325325
deprecatedKeyPaths.foreach {
@@ -362,7 +362,7 @@ object NodeParams extends Logging {
362362
require(fulfillSafetyBeforeTimeout * 2 < expiryDelta, "channel.fulfill-safety-before-timeout-blocks must be smaller than channel.expiry-delta-blocks / 2 because it effectively reduces that delta; if you want to increase this value, you may want to increase expiry-delta-blocks as well")
363363
val minFinalExpiryDelta = CltvExpiryDelta(config.getInt("channel.min-final-expiry-delta-blocks"))
364364
require(minFinalExpiryDelta > fulfillSafetyBeforeTimeout, "channel.min-final-expiry-delta-blocks must be strictly greater than channel.fulfill-safety-before-timeout-blocks; otherwise it may lead to undesired channel closure")
365-
require(config.getInt("channel.min-depth-funding-blocks") >= 6, "channel.min-depth-funding-blocks must be at least 6 to ensure that channels are safe from reorgs, otherwise funds can be stolen")
365+
require(config.getInt("channel.min-depth-blocks") >= 6, "channel.min-depth-blocks must be at least 6 to ensure that channels are safe from reorgs, otherwise funds can be stolen")
366366

367367
val nodeAlias = config.getString("node-alias")
368368
require(nodeAlias.getBytes("UTF-8").length <= 32, "invalid alias, too long (max allowed 32 bytes)")
@@ -575,8 +575,7 @@ object NodeParams extends Logging {
575575
minFundingPrivateSatoshis = Satoshi(config.getLong("channel.min-private-funding-satoshis")),
576576
toRemoteDelay = offeredCLTV,
577577
maxToLocalDelay = maxToLocalCLTV,
578-
minDepthFunding = config.getInt("channel.min-depth-funding-blocks"),
579-
minDepthClosing = config.getInt("channel.min-depth-closing-blocks"),
578+
minDepth = config.getInt("channel.min-depth-blocks"),
580579
expiryDelta = expiryDelta,
581580
maxExpiryDelta = maxExpiryDelta,
582581
fulfillSafetyBeforeTimeout = fulfillSafetyBeforeTimeout,

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
@@ -196,6 +196,7 @@ object CheckBalance {
196196
case (r, d: DATA_NORMAL) => r.modify(_.normal).using(updateMainAndHtlcBalance(d.commitments, knownPreimages))
197197
case (r, d: DATA_SHUTDOWN) => r.modify(_.shutdown).using(updateMainAndHtlcBalance(d.commitments, knownPreimages))
198198
case (r, d: DATA_NEGOTIATING) => r.modify(_.negotiating).using(updateMainBalance(d.commitments.latest.localCommit))
199+
case (r, d: DATA_NEGOTIATING_SIMPLE) => r.modify(_.negotiating).using(updateMainBalance(d.commitments.latest.localCommit))
199200
case (r, d: DATA_CLOSING) =>
200201
Closing.isClosingTypeAlreadyKnown(d) match {
201202
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/blockchain/bitcoind/ZmqWatcher.scala

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,8 @@ object ZmqWatcher {
133133
case class WatchFundingSpent(replyTo: ActorRef[WatchFundingSpentTriggered], txId: TxId, outputIndex: Int, hints: Set[TxId]) extends WatchSpent[WatchFundingSpentTriggered]
134134
case class WatchFundingSpentTriggered(spendingTx: Transaction) extends WatchSpentTriggered
135135

136-
case class WatchOutputSpent(replyTo: ActorRef[WatchOutputSpentTriggered], txId: TxId, outputIndex: Int, hints: Set[TxId]) extends WatchSpent[WatchOutputSpentTriggered]
137-
case class WatchOutputSpentTriggered(spendingTx: Transaction) extends WatchSpentTriggered
136+
case class WatchOutputSpent(replyTo: ActorRef[WatchOutputSpentTriggered], txId: TxId, outputIndex: Int, amount: Satoshi, hints: Set[TxId]) extends WatchSpent[WatchOutputSpentTriggered]
137+
case class WatchOutputSpentTriggered(amount: Satoshi, spendingTx: Transaction) extends WatchSpentTriggered
138138

139139
/** Waiting for a wallet transaction to be published guarantees that bitcoind won't double-spend it in the future, unless we explicitly call abandontransaction. */
140140
case class WatchPublished(replyTo: ActorRef[WatchPublishedTriggered], txId: TxId) extends Watch[WatchPublishedTriggered]
@@ -233,7 +233,7 @@ private class ZmqWatcher(nodeParams: NodeParams, blockHeight: AtomicLong, client
233233
.foreach {
234234
case w: WatchExternalChannelSpent => context.self ! TriggerEvent(w.replyTo, w, WatchExternalChannelSpentTriggered(w.shortChannelId, tx))
235235
case w: WatchFundingSpent => context.self ! TriggerEvent(w.replyTo, w, WatchFundingSpentTriggered(tx))
236-
case w: WatchOutputSpent => context.self ! TriggerEvent(w.replyTo, w, WatchOutputSpentTriggered(tx))
236+
case w: WatchOutputSpent => context.self ! TriggerEvent(w.replyTo, w, WatchOutputSpentTriggered(w.amount, tx))
237237
case _: WatchPublished => // nothing to do
238238
case _: WatchConfirmed[_] => // nothing to do
239239
}

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
@@ -643,6 +644,16 @@ final case class DATA_NEGOTIATING(commitments: Commitments,
643644
require(closingTxProposed.nonEmpty, "there must always be a list for the current negotiation")
644645
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")
645646
}
647+
final case class DATA_NEGOTIATING_SIMPLE(commitments: Commitments,
648+
lastClosingFeerate: FeeratePerKw,
649+
localScriptPubKey: ByteVector, remoteScriptPubKey: ByteVector,
650+
// Closing transactions we created, where we pay the fees (unsigned).
651+
proposedClosingTxs: List[ClosingTxs],
652+
// Closing transactions we published: this contains our local transactions for
653+
// which they sent a signature, and their closing transactions that we signed.
654+
publishedClosingTxs: List[ClosingTx]) extends ChannelDataWithCommitments {
655+
def findClosingTx(tx: Transaction): Option[ClosingTx] = publishedClosingTxs.find(_.tx.txid == tx.txid).orElse(proposedClosingTxs.flatMap(_.all).find(_.tx.txid == tx.txid))
656+
}
646657
final case class DATA_CLOSING(commitments: Commitments,
647658
waitingSince: BlockHeight, // how long since we initiated the closing
648659
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/Commitments.scala

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,11 @@ case class ChannelParams(channelId: ByteVector32,
114114
// 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
115115
// channels as the isValidFinalScriptPubkey() check would fail.
116116
val allowAnySegwit = Features.canUseFeature(localParams.initFeatures, remoteParams.initFeatures, Features.ShutdownAnySegwit)
117+
val allowOpReturn = Features.canUseFeature(localParams.initFeatures, remoteParams.initFeatures, Features.SimpleClose)
117118
val mustUseUpfrontShutdownScript = channelFeatures.hasFeature(Features.UpfrontShutdownScript)
118119
// we only enforce using the pre-generated shutdown script if option_upfront_shutdown_script is set
119120
if (mustUseUpfrontShutdownScript && localParams.upfrontShutdownScript_opt.exists(_ != localScriptPubKey)) Left(InvalidFinalScript(channelId))
120-
else if (!Closing.MutualClose.isValidFinalScriptPubkey(localScriptPubKey, allowAnySegwit)) Left(InvalidFinalScript(channelId))
121+
else if (!Closing.MutualClose.isValidFinalScriptPubkey(localScriptPubKey, allowAnySegwit, allowOpReturn)) Left(InvalidFinalScript(channelId))
121122
else Right(localScriptPubKey)
122123
}
123124

@@ -128,10 +129,11 @@ case class ChannelParams(channelId: ByteVector32,
128129
def validateRemoteShutdownScript(remoteScriptPubKey: ByteVector): Either[ChannelException, ByteVector] = {
129130
// 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.
130131
val allowAnySegwit = Features.canUseFeature(localParams.initFeatures, remoteParams.initFeatures, Features.ShutdownAnySegwit)
132+
val allowOpReturn = Features.canUseFeature(localParams.initFeatures, remoteParams.initFeatures, Features.SimpleClose)
131133
val mustUseUpfrontShutdownScript = channelFeatures.hasFeature(Features.UpfrontShutdownScript)
132134
// we only enforce using the pre-generated shutdown script if option_upfront_shutdown_script is set
133135
if (mustUseUpfrontShutdownScript && remoteParams.upfrontShutdownScript_opt.exists(_ != remoteScriptPubKey)) Left(InvalidFinalScript(channelId))
134-
else if (!Closing.MutualClose.isValidFinalScriptPubkey(remoteScriptPubKey, allowAnySegwit)) Left(InvalidFinalScript(channelId))
136+
else if (!Closing.MutualClose.isValidFinalScriptPubkey(remoteScriptPubKey, allowAnySegwit, allowOpReturn)) Left(InvalidFinalScript(channelId))
135137
else Right(remoteScriptPubKey)
136138
}
137139

0 commit comments

Comments
 (0)