Apply RBF limits to remote closing transactions (#3331)
What changed, and why it matters
This commit adds rate limits and maximum attempt caps for Replace-By-Fee (RBF) closing transactions in the Eclair Lightning node. Previously, a peer could repeatedly propose new closing transactions without strict limits, which could waste computing resources, storage, and network bandwidth. The change also applies the same limits to local and splice RBF attempts, aligning with Lightning protocol recommendations and reducing the risk of abuse or accidental resource exhaustion.
Review and deploy this patch to limit adversarial or accidental RBF abuse during channel close and splice operations. Monitor for any peer compatibility issues with the reduced attemptDeltaBlocks.
Security signals we found
Resource exhaustion via unbounded RBF attempts is now capped
Remote peer closing transaction rate-limiting added
Local closing transaction count bounded by BOLT recommendation
Splice RBF now enforces maximum attempts in addition to rate-limit
Duplicate closing transaction storage avoided
Evidence from the diff
The patch enforces remoteRbfLimits (maxAttempts and attemptDeltaBlocks) on remote closing_complete messages under option_simple_close, bounds the number of local legacy closing_signed proposals with MAX_NEGOTIATIONS_ITERATIONS, and applies the maxAttempts cap to splice RBF. It also reduces attemptDeltaBlocks from 6 to 3 and prevents duplicate closing transaction entries in publishedClosingTxs. New tests verify rate-limit and max-attempt behavior.
Changed components
eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scalaeclair-core/src/main/resources/reference.confeclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scalaInspect captured patch +94 / −10
diff --git a/eclair-core/src/main/resources/reference.conf b/eclair-core/src/main/resources/reference.conf
index ad4d14e..098fc99 100644
--- a/eclair-core/src/main/resources/reference.conf
+++ b/eclair-core/src/main/resources/reference.conf
@@ -113,7 +113,7 @@ eclair {
// Each RBF attempt adds more data that we need to store and process, so we want to limit our peers to a reasonable use of RBF.
remote-rbf-limits {
max-attempts = 10 // maximum number of RBF attempts our peer is allowed to make
- attempt-delta-blocks = 6 // minimum number of blocks between RBF attempts
+ attempt-delta-blocks = 3 // minimum number of blocks between RBF attempts
}
// Duration after which we abort a channel creation. If our peer seems unresponsive and doesn't complete the
// funding protocol in time, they're likely buggy or malicious.
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala
index e073753..6a59ed7 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala
@@ -234,8 +234,10 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
// Closee nonces are first exchanged in shutdown messages, and replaced by a new nonce after each closing_sig.
var localCloseeNonce_opt: Option[LocalNonce] = None
var remoteCloseeNonce_opt: Option[IndividualNonce] = None
- // our closing_complete message, that includes partial musig2 signatures generated with random nonces.
+ // Our closing_complete message, that includes partial musig2 signatures generated with random nonces.
var localClosingComplete_opt: Option[ClosingComplete] = None
+ // We rate-limit remote closing transactions to avoid wasting resources.
+ var lastRemoteClosingCompleteBlockHeight_opt: Option[BlockHeight] = None
// we pass these to helpers classes so that they have the logging context
implicit def implicitLog: akka.event.DiagnosticLoggingAdapter = diagLog
@@ -1255,6 +1257,9 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
// Our peer is trying to trick us into contributing the amount they were previously paying for, but
// without paying for it by leveraging the fact that we'll keep contributing the same amount.
stay() using d.copy(spliceStatus = SpliceStatus.SpliceAborted) sending TxAbort(d.channelId, InvalidRbfMissingLiquidityPurchase(d.channelId, rbf.latestFundingTx.liquidityPurchase_opt.get.amount).getMessage)
+ case Right(rbf) if nodeParams.channelConf.remoteRbfLimits.maxAttempts <= rbf.previousTransactions.length =>
+ log.info("rejecting rbf attempt: maximum number of attempts reached (max={})", nodeParams.channelConf.remoteRbfLimits.maxAttempts)
+ stay() using d.copy(spliceStatus = SpliceStatus.SpliceAborted) sending TxAbort(d.channelId, InvalidRbfAttemptsExhausted(d.channelId, nodeParams.channelConf.remoteRbfLimits.maxAttempts).getMessage)
case Right(rbf) if nodeParams.currentBlockHeight < rbf.latestFundingTx.createdAt + nodeParams.channelConf.remoteRbfLimits.attemptDeltaBlocks =>
log.info("rejecting rbf attempt: last attempt was less than {} blocks ago", nodeParams.channelConf.remoteRbfLimits.attemptDeltaBlocks)
stay() using d.copy(spliceStatus = SpliceStatus.SpliceAborted) sending TxAbort(d.channelId, InvalidRbfAttemptTooSoon(d.channelId, rbf.latestFundingTx.createdAt, rbf.latestFundingTx.createdAt + nodeParams.channelConf.remoteRbfLimits.attemptDeltaBlocks).getMessage)
@@ -1929,9 +1934,15 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
case Event(closingComplete: ClosingComplete, d: DATA_NEGOTIATING_SIMPLE) =>
// Note that if there is a failure here and we don't send our closing_sig, they may eventually disconnect.
// On reconnection, we will retransmit shutdown with our latest scripts, so future signing attempts should work.
- if (closingComplete.closeeScriptPubKey != d.localScriptPubKey) {
+ val localClosingTxs = d.proposedClosingTxs.flatMap(_.all).map(_.tx.txid).toSet
+ val remoteClosingTxCount = d.publishedClosingTxs.count(tx => !localClosingTxs.contains(tx.tx.txid))
+ if (remoteClosingTxCount >= nodeParams.channelConf.remoteRbfLimits.maxAttempts) {
+ stay() sending Warning(d.channelId, InvalidRbfAttemptsExhausted(d.channelId, nodeParams.channelConf.remoteRbfLimits.maxAttempts).getMessage)
+ } else if (lastRemoteClosingCompleteBlockHeight_opt.exists(h => nodeParams.currentBlockHeight < h + nodeParams.channelConf.remoteRbfLimits.attemptDeltaBlocks)) {
+ stay() sending Warning(d.channelId, InvalidRbfAttemptTooSoon(d.channelId, lastRemoteClosingCompleteBlockHeight_opt.get, lastRemoteClosingCompleteBlockHeight_opt.get + nodeParams.channelConf.remoteRbfLimits.attemptDeltaBlocks).getMessage)
+ } else if (closingComplete.closeeScriptPubKey != d.localScriptPubKey) {
log.warning("their closing_complete is not using our latest script: this may happen if we changed our script while they were sending closing_complete")
- // No need to persist their latest script, they will re-sent it on reconnection.
+ // No need to persist their latest script, they will re-send it on reconnection.
stay() using d.copy(remoteScriptPubKey = closingComplete.closerScriptPubKey) sending Warning(d.channelId, InvalidCloseeScript(d.channelId, closingComplete.closeeScriptPubKey, d.localScriptPubKey).getMessage)
} else {
MutualClose.signSimpleClosingTx(channelKeys, d.commitments.latest, closingComplete.closeeScriptPubKey, closingComplete.closerScriptPubKey, closingComplete, localCloseeNonce_opt) match {
@@ -1941,7 +1952,12 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
case Right((signedClosingTx, closingSig, nextCloseeNonce_opt)) =>
log.debug("signing remote mutual close transaction: {}", signedClosingTx.tx)
localCloseeNonce_opt = nextCloseeNonce_opt
- val d1 = d.copy(remoteScriptPubKey = closingComplete.closerScriptPubKey, publishedClosingTxs = d.publishedClosingTxs :+ signedClosingTx)
+ lastRemoteClosingCompleteBlockHeight_opt = Some(nodeParams.currentBlockHeight)
+ val d1 = if (!d.publishedClosingTxs.exists(_.tx.txid == signedClosingTx.tx.txid)) {
+ d.copy(remoteScriptPubKey = closingComplete.closerScriptPubKey, publishedClosingTxs = d.publishedClosingTxs :+ signedClosingTx)
+ } else {
+ d.copy(remoteScriptPubKey = closingComplete.closerScriptPubKey)
+ }
stay() using d1 storing() calling doPublish(signedClosingTx, localPaysClosingFees = false) sending closingSig
}
}
@@ -1957,7 +1973,12 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
stay() sending Warning(d.channelId, f.getMessage)
case Right(signedClosingTx) =>
log.debug("received signatures for local mutual close transaction: {}", signedClosingTx.tx)
- val d1 = d.copy(publishedClosingTxs = d.publishedClosingTxs :+ signedClosingTx)
+ val d1 = if (!d.publishedClosingTxs.exists(_.tx.txid == signedClosingTx.tx.txid)) {
+ d.copy(publishedClosingTxs = d.publishedClosingTxs :+ signedClosingTx)
+ } else {
+ // Since we're using a list and not a set, we explicitly avoid storing duplicate transactions.
+ d
+ }
remoteCloseeNonce_opt = closingSig.nextCloseeNonce_opt
stay() using d1 storing() calling doPublish(signedClosingTx, localPaysClosingFees = true)
}
@@ -2842,9 +2863,13 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
// note: in any case we still need to keep all previously sent closing_signed, because they may publish one of them
if (d.commitments.localChannelParams.paysClosingFees) {
// we could use the last closing_signed we sent, but network fees may have changed while we were offline so it is better to restart from scratch
- val (closingTx, closingSigned) = Closing.MutualClose.makeFirstClosingTx(channelKeys, d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, nodeParams.currentFeeratesForFundingClosing, nodeParams.onChainFeeConf, None)
- val closingTxProposed1 = d.closingTxProposed :+ List(ClosingTxProposed(closingTx, closingSigned))
- goto(NEGOTIATING) using d.copy(closingTxProposed = closingTxProposed1) storing() sending d.localShutdown :: closingSigned :: Nil
+ if (d.closingTxProposed.flatten.size < MAX_NEGOTIATION_ITERATIONS) {
+ val (closingTx, closingSigned) = Closing.MutualClose.makeFirstClosingTx(channelKeys, d.commitments.latest, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, nodeParams.currentFeeratesForFundingClosing, nodeParams.onChainFeeConf, None)
+ val closingTxProposed1 = d.closingTxProposed :+ List(ClosingTxProposed(closingTx, closingSigned))
+ goto(NEGOTIATING) using d.copy(closingTxProposed = closingTxProposed1) storing() sending d.localShutdown :: closingSigned :: Nil
+ } else {
+ goto(NEGOTIATING) using d sending d.localShutdown
+ }
} else {
// we start a new round of negotiation
val closingTxProposed1 = if (d.closingTxProposed.last.isEmpty) d.closingTxProposed else d.closingTxProposed :+ List()
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala
index 2fa3aba..c23e563 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala
@@ -49,7 +49,7 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike
type FixtureParam = SetupFixture
override def withFixture(test: OneArgTest): Outcome = {
- val setup = init()
+ val setup = init(tags = test.tags)
within(30 seconds) {
reachNormal(setup, test.tags)
withFixture(test.toNoArgTest(setup))
@@ -749,6 +749,65 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike
alice2bob.expectNoMessage(100 millis)
}
+ test("recv ClosingComplete (rate-limited)", Tag(ChannelStateTestsTags.SimpleClose), Tag(ChannelStateTestsTags.DelayRbfAttempts)) { f =>
+ import f._
+
+ aliceClose(f)
+ alice2bob.expectMsgType[ClosingComplete]
+ alice2bob.forward(bob)
+ bob2alice.expectMsgType[ClosingComplete]
+ bob2alice.forward(alice)
+ alice2bob.expectMsgType[ClosingSig]
+ alice2bob.forward(bob)
+ bob2alice.expectMsgType[ClosingSig]
+ bob2alice.forward(alice)
+
+ // The next closing transaction must be after the next block is found.
+ val probe = TestProbe()
+ val currentBlockHeight = bob.underlyingActor.nodeParams.currentBlockHeight
+ val nextFeerate = alice.stateData.asInstanceOf[DATA_NEGOTIATING_SIMPLE].lastClosingFeerate * 1.25
+ alice ! CMD_CLOSE(probe.ref, None, Some(ClosingFeerates(nextFeerate, nextFeerate, nextFeerate)))
+ probe.expectMsgType[RES_SUCCESS[CMD_CLOSE]]
+ alice2bob.expectMsgType[ClosingComplete]
+ alice2bob.forward(bob)
+ assert(bob2alice.expectMsgType[Warning].toAscii == InvalidRbfAttemptTooSoon(channelId(alice), currentBlockHeight, currentBlockHeight + 1).getMessage)
+ bob2alice.expectNoMessage(100 millis)
+ }
+
+ test("recv ClosingComplete (max attempts exhausted)", Tag(ChannelStateTestsTags.SimpleClose)) { f =>
+ import f._
+
+ aliceClose(f)
+ alice2bob.expectMsgType[ClosingComplete]
+ alice2bob.forward(bob)
+ bob2alice.expectMsgType[ClosingComplete]
+ bob2alice.forward(alice)
+ alice2bob.expectMsgType[ClosingSig]
+ alice2bob.forward(bob)
+ bob2alice.expectMsgType[ClosingSig]
+ bob2alice.forward(alice)
+
+ // We allow a few closing transactions and reject new ones after reaching our limit.
+ val probe = TestProbe()
+ (1 to 4).foreach(_ => {
+ val nextFeerate = bob.stateData.asInstanceOf[DATA_NEGOTIATING_SIMPLE].lastClosingFeerate * 1.25
+ bob ! CMD_CLOSE(probe.ref, None, Some(ClosingFeerates(nextFeerate, nextFeerate, nextFeerate)))
+ probe.expectMsgType[RES_SUCCESS[CMD_CLOSE]]
+ bob2alice.expectMsgType[ClosingComplete]
+ bob2alice.forward(alice)
+ alice2bob.expectMsgType[ClosingSig]
+ alice2bob.forward(bob)
+ })
+
+ val nextFeerate = bob.stateData.asInstanceOf[DATA_NEGOTIATING_SIMPLE].lastClosingFeerate * 1.25
+ bob ! CMD_CLOSE(probe.ref, None, Some(ClosingFeerates(nextFeerate, nextFeerate, nextFeerate)))
+ probe.expectMsgType[RES_SUCCESS[CMD_CLOSE]]
+ bob2alice.expectMsgType[ClosingComplete]
+ bob2alice.forward(alice)
+ assert(alice2bob.expectMsgType[Warning].toAscii == InvalidRbfAttemptsExhausted(channelId(alice), 5).getMessage)
+ alice2bob.expectNoMessage(100 millis)
+ }
+
test("recv WatchFundingSpentTriggered (counterparty's mutual close)") { f =>
import f._
aliceClose(f)
Why this scored 52/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.