Skip to content

Commit 9c85128

Browse files
authored
Remove amount-based confirmation scaling (#3044)
We previously scaled the number of confirmations based on the channel capacity: however, this doesn't really work with splicing, as shown by the following attack scenario: - a malicious node opens a very large channel - we thus wait for more confirmations to protect against reorgs - they then send lightning payments until all of the balance is on our side of the channel - we splice-out to shrink the channel to a minimal size - if we thus us a smaller number of confirmations (because the channel is now small), the malicious node can perform this small reorg and publish one of their revoked commitment There are more involved variations of this attack, which show that the real amount at stake is potentially much more than a single channel's current capacity. Instead of trying to find a complex solution that would likely have subtle edge cases, it's a lot simpler to always use a static number of confirmations that is large enough to protect against reorgs entirely.
1 parent 9210dff commit 9c85128

21 files changed

+76
-292
lines changed

docs/release-notes/eclair-vnext.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,20 @@
1212

1313
### Miscellaneous improvements and bug fixes
1414

15-
<insert changes>
15+
#### Remove confirmation scaling based on funding amount
16+
17+
We previously scaled the number of confirmations based on the channel funding amount.
18+
However, this doesn't work with splicing, where the channel capacity may change drastically.
19+
It's much simpler to always use the same number of confirmations, while choosing a value that is large enough to protect against malicious reorgs.
20+
We now by default use 8 confirmations, which can be modified in `eclair.conf`:
21+
22+
```conf
23+
// Minimum number of confirmations for channel transactions to be safe from reorgs.
24+
eclair.channel.min-depth-blocks = 8
25+
```
26+
27+
Note however that we require `min-depth` to be at least 6 blocks, since the BOLTs require this before announcing channels.
28+
See #3044 for more details.
1629

1730
## Verifying signatures
1831

eclair-core/src/main/resources/reference.conf

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ eclair {
133133

134134
to-remote-delay-blocks = 720 // number of blocks that the other node's to-self outputs must be delayed (720 ~ 5 days)
135135
max-to-local-delay-blocks = 2016 // maximum number of blocks that we are ready to accept for our own delayed outputs (2016 ~ 2 weeks)
136-
min-depth-blocks = 6 // minimum number of confirmations for channel transactions, which we will additionally scale based on the amount at stake
136+
min-depth-blocks = 8 // minimum number of confirmations for channel transactions to be safe from reorgs
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/blockchain/bitcoind/ZmqWatcher.scala

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ object ZmqWatcher {
8888
/** TxId of the transaction to watch. */
8989
def txId: TxId
9090
/** Number of confirmations. */
91-
def minDepth: Long
91+
def minDepth: Int
9292
}
9393

9494
/**
@@ -140,17 +140,17 @@ object ZmqWatcher {
140140
case class WatchPublished(replyTo: ActorRef[WatchPublishedTriggered], txId: TxId) extends Watch[WatchPublishedTriggered]
141141
case class WatchPublishedTriggered(tx: Transaction) extends WatchTriggered
142142

143-
case class WatchFundingConfirmed(replyTo: ActorRef[WatchFundingConfirmedTriggered], txId: TxId, minDepth: Long) extends WatchConfirmed[WatchFundingConfirmedTriggered]
143+
case class WatchFundingConfirmed(replyTo: ActorRef[WatchFundingConfirmedTriggered], txId: TxId, minDepth: Int) extends WatchConfirmed[WatchFundingConfirmedTriggered]
144144
case class WatchFundingConfirmedTriggered(blockHeight: BlockHeight, txIndex: Int, tx: Transaction) extends WatchConfirmedTriggered
145145

146146
case class RelativeDelay(parentTxId: TxId, delay: Long)
147-
case class WatchTxConfirmed(replyTo: ActorRef[WatchTxConfirmedTriggered], txId: TxId, minDepth: Long, delay_opt: Option[RelativeDelay] = None) extends WatchConfirmed[WatchTxConfirmedTriggered]
147+
case class WatchTxConfirmed(replyTo: ActorRef[WatchTxConfirmedTriggered], txId: TxId, minDepth: Int, delay_opt: Option[RelativeDelay] = None) extends WatchConfirmed[WatchTxConfirmedTriggered]
148148
case class WatchTxConfirmedTriggered(blockHeight: BlockHeight, txIndex: Int, tx: Transaction) extends WatchConfirmedTriggered
149149

150-
case class WatchParentTxConfirmed(replyTo: ActorRef[WatchParentTxConfirmedTriggered], txId: TxId, minDepth: Long) extends WatchConfirmed[WatchParentTxConfirmedTriggered]
150+
case class WatchParentTxConfirmed(replyTo: ActorRef[WatchParentTxConfirmedTriggered], txId: TxId, minDepth: Int) extends WatchConfirmed[WatchParentTxConfirmedTriggered]
151151
case class WatchParentTxConfirmedTriggered(blockHeight: BlockHeight, txIndex: Int, tx: Transaction) extends WatchConfirmedTriggered
152152

153-
case class WatchAlternativeCommitTxConfirmed(replyTo: ActorRef[WatchAlternativeCommitTxConfirmedTriggered], txId: TxId, minDepth: Long) extends WatchConfirmed[WatchAlternativeCommitTxConfirmedTriggered]
153+
case class WatchAlternativeCommitTxConfirmed(replyTo: ActorRef[WatchAlternativeCommitTxConfirmedTriggered], txId: TxId, minDepth: Int) extends WatchConfirmed[WatchAlternativeCommitTxConfirmedTriggered]
154154
case class WatchAlternativeCommitTxConfirmedTriggered(blockHeight: BlockHeight, txIndex: Int, tx: Transaction) extends WatchConfirmedTriggered
155155

156156
private sealed trait AddWatchResult

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

Lines changed: 6 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,7 @@ import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, Crypto, Satoshi
77
import fr.acinq.eclair.blockchain.fee.{FeeratePerByte, FeeratePerKw, FeeratesPerKw, OnChainFeeConf}
88
import fr.acinq.eclair.channel.Helpers.Closing
99
import fr.acinq.eclair.channel.Monitoring.{Metrics, Tags}
10-
import fr.acinq.eclair.channel.fsm.Channel
1110
import fr.acinq.eclair.channel.fsm.Channel.ChannelConf
12-
import fr.acinq.eclair.channel.fund.InteractiveTxBuilder.SharedTransaction
1311
import fr.acinq.eclair.crypto.keymanager.ChannelKeyManager
1412
import fr.acinq.eclair.crypto.{Generators, ShaChain}
1513
import fr.acinq.eclair.payment.OutgoingPaymentPacket
@@ -40,6 +38,9 @@ case class ChannelParams(channelId: ByteVector32,
4038
// We can safely cast to millisatoshis since we verify that it's less than a valid millisatoshi amount.
4139
val maxHtlcAmount: MilliSatoshi = remoteParams.maxHtlcValueInFlightMsat.toBigInt.min(localParams.maxHtlcValueInFlightMsat.toLong).toLong.msat
4240

41+
// If we've set the 0-conf feature bit for this peer, we will always use 0-conf with them.
42+
val zeroConf: Boolean = localParams.initFeatures.hasFeature(Features.ZeroConf)
43+
4344
/**
4445
* We update local/global features at reconnection
4546
*/
@@ -49,47 +50,10 @@ case class ChannelParams(channelId: ByteVector32,
4950
)
5051

5152
/**
52-
* Returns the number of confirmations needed to safely handle a funding transaction that we unilaterally funded.
53-
* As funder we trust ourselves to not double spend funding txs, so we don't need to scale the number of confirmations
54-
* based on the funding amount. We want to wait a few blocks though to ensure that the short_channel_id we obtain will
55-
* not be invalidated by a reorg.
53+
* Returns the number of confirmations needed to make a channel transaction safe from reorgs.
54+
* A malicious miner that can create a longer reorg will be able to steal all of the channel funds.
5655
*/
57-
def minDepthFunder(defaultMinDepth: Int): Option[Long] = {
58-
if (localParams.initFeatures.hasFeature(Features.ZeroConf)) {
59-
None
60-
} else {
61-
Some(defaultMinDepth.toLong)
62-
}
63-
}
64-
65-
/**
66-
* Returns the number of confirmations needed to safely handle a funding transaction with remote inputs. We make sure
67-
* the cumulative block reward largely exceeds the channel size, because an attacker that could create a reorg would
68-
* be able to steal the entire channel funding, but would likely miss block rewards during that process, making it
69-
* economically irrational for them.
70-
*
71-
* @param fundingAmount funding amount of the channel
72-
* @return number of confirmations needed, if any
73-
*/
74-
def minDepthFundee(defaultMinDepth: Int, fundingAmount: Satoshi): Option[Long] =
75-
if (localParams.initFeatures.hasFeature(Features.ZeroConf)) {
76-
None // zero-conf stay zero-conf, whatever the funding amount is
77-
} else {
78-
Some(ChannelParams.minDepthScaled(defaultMinDepth, fundingAmount))
79-
}
80-
81-
/**
82-
* When using dual funding or splices, we wait for multiple confirmations even if we're the initiator because:
83-
* - our peer may also contribute to the funding transaction, even if they don't contribute to the channel funding amount
84-
* - even if they don't, we may RBF the transaction and don't want to handle reorgs
85-
*/
86-
def minDepthDualFunding(defaultMinDepth: Int, sharedTx: SharedTransaction): Option[Long] = {
87-
if (localParams.initFeatures.hasFeature(Features.ZeroConf)) {
88-
None
89-
} else {
90-
Some(ChannelParams.minDepthScaled(defaultMinDepth, sharedTx.sharedOutput.amount))
91-
}
92-
}
56+
def minDepth(defaultMinDepth: Int): Option[Int] = if (zeroConf) None else Some(defaultMinDepth)
9357

9458
/** Channel reserve that applies to our funds. */
9559
def localChannelReserveForCapacity(capacity: Satoshi, isSplice: Boolean): Satoshi = if (channelFeatures.hasFeature(Features.DualFunding) || isSplice) {
@@ -139,20 +103,6 @@ case class ChannelParams(channelId: ByteVector32,
139103

140104
}
141105

142-
object ChannelParams {
143-
def minDepthScaled(defaultMinDepth: Int, amount: Satoshi): Int = {
144-
if (amount <= Channel.MAX_FUNDING_WITHOUT_WUMBO) {
145-
// small amount: not scaled
146-
defaultMinDepth
147-
} else {
148-
val blockReward = 3.125 // this will be too large after the halving in 2028
149-
val scalingFactor = 10
150-
val blocksToReachFunding = (((scalingFactor * amount.toBtc.toDouble) / blockReward).ceil + 1).toInt
151-
defaultMinDepth.max(blocksToReachFunding)
152-
}
153-
}
154-
}
155-
156106
// @formatter:off
157107
case class LocalChanges(proposed: List[UpdateMessage], signed: List[UpdateMessage], acked: List[UpdateMessage]) {
158108
def all: List[UpdateMessage] = proposed ++ signed ++ acked

eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,6 @@ object Channel {
112112
require(balanceThresholds.sortBy(_.available) == balanceThresholds, "channel-update.balance-thresholds must be sorted by available-sat")
113113

114114
def minFundingSatoshis(flags: ChannelFlags): Satoshi = if (flags.announceChannel) minFundingPublicSatoshis else minFundingPrivateSatoshis
115-
116-
/** The number of confirmations required to be safe from reorgs is always scaled based on the amount at risk. */
117-
def minDepthScaled(amount: Satoshi): Int = ChannelParams.minDepthScaled(minDepth, amount)
118115
}
119116

120117
trait TxPublisherFactory {
@@ -320,14 +317,13 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
320317
// once, because at the next restore, the status of the funding tx will be "confirmed".
321318
()
322319
}
323-
watchFundingConfirmed(commitment.fundingTxId, Some(singleFundingMinDepth(data)), herdDelay_opt)
320+
watchFundingConfirmed(commitment.fundingTxId, Some(nodeParams.channelConf.minDepth), herdDelay_opt)
324321
case fundingTx: LocalFundingStatus.DualFundedUnconfirmedFundingTx =>
325322
publishFundingTx(fundingTx)
326-
val minDepth_opt = data.commitments.params.minDepthDualFunding(nodeParams.channelConf.minDepth, fundingTx.sharedTx.tx)
323+
val minDepth_opt = data.commitments.params.minDepth(nodeParams.channelConf.minDepth)
327324
watchFundingConfirmed(fundingTx.sharedTx.txId, minDepth_opt, herdDelay_opt)
328325
case fundingTx: LocalFundingStatus.ZeroconfPublishedFundingTx =>
329-
// This is a zero-conf channel, the min-depth isn't critical: we use the default.
330-
watchFundingConfirmed(fundingTx.tx.txid, Some(nodeParams.channelConf.minDepth.toLong), herdDelay_opt)
326+
watchFundingConfirmed(fundingTx.tx.txid, Some(nodeParams.channelConf.minDepth), herdDelay_opt)
331327
case _: LocalFundingStatus.ConfirmedFundingTx =>
332328
data match {
333329
case closing: DATA_CLOSING if Closing.nothingAtStake(closing) || Closing.isClosingTypeAlreadyKnown(closing).isDefined =>
@@ -612,7 +608,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
612608
// We don't have their tx_sigs, but they have ours, and could publish the funding tx without telling us.
613609
// That's why we move on immediately to the next step, and will update our unsigned funding tx when we
614610
// receive their tx_sigs.
615-
val minDepth_opt = d.commitments.params.minDepthDualFunding(nodeParams.channelConf.minDepth, signingSession1.fundingTx.sharedTx.tx)
611+
val minDepth_opt = d.commitments.params.minDepth(nodeParams.channelConf.minDepth)
616612
watchFundingConfirmed(signingSession.fundingTx.txId, minDepth_opt, delay_opt = None)
617613
val commitments1 = d.commitments.add(signingSession1.commitment)
618614
val d1 = d.copy(commitments = commitments1, spliceStatus = SpliceStatus.NoSplice)
@@ -1349,7 +1345,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
13491345
rollbackFundingAttempt(signingSession.fundingTx.tx, previousTxs = Seq.empty) // no splice rbf yet
13501346
stay() using d.copy(spliceStatus = SpliceStatus.SpliceAborted) sending TxAbort(d.channelId, f.getMessage)
13511347
case Right(signingSession1) =>
1352-
val minDepth_opt = d.commitments.params.minDepthDualFunding(nodeParams.channelConf.minDepth, signingSession1.fundingTx.sharedTx.tx)
1348+
val minDepth_opt = d.commitments.params.minDepth(nodeParams.channelConf.minDepth)
13531349
watchFundingConfirmed(signingSession.fundingTx.txId, minDepth_opt, delay_opt = None)
13541350
val commitments1 = d.commitments.add(signingSession1.commitment)
13551351
val d1 = d.copy(commitments = commitments1, spliceStatus = SpliceStatus.NoSplice)
@@ -1368,7 +1364,6 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
13681364
val fundingStatus = LocalFundingStatus.ZeroconfPublishedFundingTx(w.tx, d.commitments.localFundingSigs(w.tx.txid), d.commitments.liquidityPurchase(w.tx.txid))
13691365
d.commitments.updateLocalFundingStatus(w.tx.txid, fundingStatus, d.lastAnnouncedFundingTxId_opt) match {
13701366
case Right((commitments1, commitment)) =>
1371-
// This is a zero-conf channel, the min-depth isn't critical: we use the default.
13721367
watchFundingConfirmed(w.tx.txid, Some(nodeParams.channelConf.minDepth), delay_opt = None)
13731368
maybeEmitEventsPostSplice(d.aliases, d.commitments, commitments1, d.lastAnnouncement_opt)
13741369
maybeUpdateMaxHtlcAmount(d.channelUpdate.htlcMaximumMsat, commitments1)
@@ -1960,7 +1955,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
19601955
d.commitments.resolveCommitment(tx) match {
19611956
case Some(commitment) =>
19621957
log.warning("a commit tx for an older commitment has been published fundingTxId={} fundingTxIndex={}", tx.txid, commitment.fundingTxIndex)
1963-
blockchain ! WatchAlternativeCommitTxConfirmed(self, tx.txid, nodeParams.channelConf.minDepthScaled(commitment.capacity))
1958+
blockchain ! WatchAlternativeCommitTxConfirmed(self, tx.txid, nodeParams.channelConf.minDepth)
19641959
stay()
19651960
case None =>
19661961
// This must be a former funding tx that has already been pruned, because watches are unordered.
@@ -2029,7 +2024,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
20292024
case Event(WatchOutputSpentTriggered(amount, tx), d: DATA_CLOSING) =>
20302025
// one of the outputs of the local/remote/revoked commit was spent
20312026
// we just put a watch to be notified when it is confirmed
2032-
blockchain ! WatchTxConfirmed(self, tx.txid, nodeParams.channelConf.minDepthScaled(amount))
2027+
blockchain ! WatchTxConfirmed(self, tx.txid, nodeParams.channelConf.minDepth)
20332028
// when a remote or local commitment tx containing outgoing htlcs is published on the network,
20342029
// we watch it in order to extract payment preimage if funds are pulled by the counterparty
20352030
// we can then use these preimages to fulfill origin htlcs
@@ -2064,7 +2059,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
20642059
val (localCommitPublished1, claimHtlcTx_opt) = Closing.LocalClose.claimHtlcDelayedOutput(localCommitPublished, keyManager, d.commitments.latest, tx, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf, d.finalScriptPubKey)
20652060
claimHtlcTx_opt.foreach(claimHtlcTx => {
20662061
txPublisher ! PublishFinalTx(claimHtlcTx, claimHtlcTx.fee, None)
2067-
blockchain ! WatchTxConfirmed(self, claimHtlcTx.tx.txid, nodeParams.channelConf.minDepthScaled(claimHtlcTx.amountIn), Some(RelativeDelay(tx.txid, d.commitments.params.remoteParams.toSelfDelay.toInt.toLong)))
2062+
blockchain ! WatchTxConfirmed(self, claimHtlcTx.tx.txid, nodeParams.channelConf.minDepth, Some(RelativeDelay(tx.txid, d.commitments.params.remoteParams.toSelfDelay.toInt.toLong)))
20682063
})
20692064
Closing.updateLocalCommitPublished(localCommitPublished1, tx)
20702065
}),
@@ -2841,7 +2836,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
28412836
} else {
28422837
// This is one of the transactions we published.
28432838
val closingTx = d.findClosingTx(tx).get
2844-
blockchain ! WatchTxConfirmed(self, tx.txid, nodeParams.channelConf.minDepthScaled(closingTx.amountIn))
2839+
blockchain ! WatchTxConfirmed(self, tx.txid, nodeParams.channelConf.minDepth)
28452840
stay()
28462841
}
28472842

@@ -2869,7 +2864,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
28692864
case Some(commitment) =>
28702865
log.warning("a commit tx for an older commitment has been published fundingTxId={} fundingTxIndex={}", tx.txid, commitment.fundingTxIndex)
28712866
// We watch the commitment tx, in the meantime we force close using the latest commitment.
2872-
blockchain ! WatchAlternativeCommitTxConfirmed(self, tx.txid, nodeParams.channelConf.minDepthScaled(commitment.capacity))
2867+
blockchain ! WatchAlternativeCommitTxConfirmed(self, tx.txid, nodeParams.channelConf.minDepth)
28732868
spendLocalCurrent(d)
28742869
case None =>
28752870
// This must be a former funding tx that has already been pruned, because watches are unordered.

0 commit comments

Comments
 (0)