What changed, and why it matters
This change makes the Eclair Lightning node force-close a payment channel if a peer tries to add a payment whose timeout value (cltv_expiry) is 500,000,000 or higher. Such large values are invalid according to the Lightning BOLT 2 specification because they would be interpreted as a Unix timestamp rather than a Bitcoin block height, which could confuse the node and lead to incorrect fund-locking behavior. Previously Eclair rejected these values without force-closing; now it explicitly closes the channel as the spec recommends.
Review the off-by-one change in Scripts.cltvTimeout() for any edge cases with lockTime exactly equal to 500,000,000, and ensure the force-close path does not introduce denial-of-service risks where a malicious peer could cheaply induce channel closures. Consider whether the existing large-cltv_expiry rejection already provided sufficient protection before this change.
Security signals we found
BOLT 2 compliance check added for HTLC cltv_expiry >= 500,000,000
Invalid cltv_expiry now triggers local error and channel force-close
Off-by-one fix in locktime threshold interpretation (<= changed to <)
New regression test for force-close on invalid cltv_expiry
Evidence from the diff
The commit adds validation in Commitments.receiveAdd() to return ExpiryTooBig when an incoming UpdateAddHtlc has cltvExpiry >= LOCKTIME_THRESHOLD (500,000,000). The Channel FSM then routes this through handleLocalError, which force-closes the channel. A related off-by-one fix in Scripts.cltvTimeout() changes the locktime interpretation from <= to < LOCKTIME_THRESHOLD, aligning block-height vs. timestamp semantics. Tests are updated to pass currentBlockHeight to receiveAdd and a new NormalStateSpec test verifies force-close on cltv_expiry = 500,000,000.
Changed components
eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scalaeclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scalaeclair-core/src/main/scala/fr/acinq/eclair/transactions/Scripts.scalaeclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scalaeclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scalaInspect captured patch +32 / −14
### eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala
@@ -1,6 +1,7 @@
package fr.acinq.eclair.channel
import akka.event.LoggingAdapter
+import fr.acinq.bitcoin.Script.LOCKTIME_THRESHOLD
import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey}
import fr.acinq.bitcoin.scalacompat.Musig2.IndividualNonce
import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, Crypto, OutPoint, Satoshi, SatoshiLong, Transaction, TxId}
@@ -953,11 +954,16 @@ case class Commitments(channelParams: ChannelParams,
}
}
- def receiveAdd(add: UpdateAddHtlc): Either[ChannelException, Commitments] = {
+ def receiveAdd(add: UpdateAddHtlc, currentBlockHeight: BlockHeight): Either[ChannelException, Commitments] = {
if (add.id != changes.remoteNextHtlcId) {
return Left(UnexpectedHtlcId(channelId, expected = changes.remoteNextHtlcId, actual = add.id))
}
+ // CLTV expiry values >= 500_000_000 would indicate a time in seconds instead of a block height.
+ if (add.cltvExpiry >= CltvExpiry(LOCKTIME_THRESHOLD)) {
+ return Left(ExpiryTooBig(channelId, CltvExpiry(LOCKTIME_THRESHOLD), add.cltvExpiry, currentBlockHeight))
+ }
+
// we used to not enforce a strictly positive minimum, hence the max(1 msat)
val htlcMinimum = active.map(_.localCommitParams.htlcMinimum).max.max(1 msat)
if (add.amountMsat < htlcMinimum) {
### eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala
@@ -557,7 +557,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
}
case Event(add: UpdateAddHtlc, d: DATA_NORMAL) =>
- d.commitments.receiveAdd(add) match {
+ d.commitments.receiveAdd(add, nodeParams.currentBlockHeight) match {
case Right(commitments1) => stay() using d.copy(commitments = commitments1)
case Left(cause) => handleLocalError(cause, d, Some(add))
}
### eclair-core/src/main/scala/fr/acinq/eclair/transactions/Scripts.scala
@@ -26,7 +26,6 @@ import fr.acinq.bitcoin.scalacompat._
import fr.acinq.eclair.crypto.keymanager.{CommitmentPublicKeys, LocalCommitmentKeys, RemoteCommitmentKeys}
import fr.acinq.eclair.transactions.Transactions._
import fr.acinq.eclair.{BlockHeight, CltvExpiry, CltvExpiryDelta}
-import fr.acinq.secp256k1.Secp256k1
import scodec.bits.ByteVector
import scala.util.{Success, Try}
@@ -100,7 +99,7 @@ object Scripts {
* @return the block height before which this tx cannot be published.
*/
def cltvTimeout(tx: Transaction): BlockHeight =
- if (tx.lockTime <= LOCKTIME_THRESHOLD) {
+ if (tx.lockTime < LOCKTIME_THRESHOLD) {
// locktime is a number of blocks
BlockHeight(tx.lockTime)
} else {
### eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala
@@ -85,7 +85,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with
assert(ac1.availableBalanceForSend == a - p - htlcOutputFee) // as soon as htlc is sent, alice sees its balance decrease (more than the payment amount because of the commitment fees)
assert(ac1.availableBalanceForReceive == b)
- val Right(bc1) = bc0.receiveAdd(add)
+ val Right(bc1) = bc0.receiveAdd(add, currentBlockHeight)
assert(bc1.availableBalanceForSend == b)
assert(bc1.availableBalanceForReceive == a - p - htlcOutputFee)
@@ -170,7 +170,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with
assert(ac1.availableBalanceForSend == a - p - htlcOutputFee) // as soon as htlc is sent, alice sees its balance decrease (more than the payment amount because of the commitment fees)
assert(ac1.availableBalanceForReceive == b)
- val Right(bc1) = bc0.receiveAdd(add)
+ val Right(bc1) = bc0.receiveAdd(add, currentBlockHeight)
assert(bc1.availableBalanceForSend == b)
assert(bc1.availableBalanceForReceive == a - p - htlcOutputFee)
@@ -268,15 +268,15 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with
assert(bc1.availableBalanceForSend == b - p3) // bob doesn't pay the fee
assert(bc1.availableBalanceForReceive == a)
- val Right(bc2) = bc1.receiveAdd(add1)
+ val Right(bc2) = bc1.receiveAdd(add1, currentBlockHeight)
assert(bc2.availableBalanceForSend == b - p3)
assert(bc2.availableBalanceForReceive == a - p1 - htlcOutputFee)
- val Right(bc3) = bc2.receiveAdd(add2)
+ val Right(bc3) = bc2.receiveAdd(add2, currentBlockHeight)
assert(bc3.availableBalanceForSend == b - p3)
assert(bc3.availableBalanceForReceive == a - p1 - htlcOutputFee - p2 - htlcOutputFee)
- val Right(ac3) = ac2.receiveAdd(add3)
+ val Right(ac3) = ac2.receiveAdd(add3, currentBlockHeight)
assert(ac3.availableBalanceForSend == a - p1 - htlcOutputFee - p2 - htlcOutputFee)
assert(ac3.availableBalanceForReceive == b - p3)
@@ -409,7 +409,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with
for (isInitiator <- Seq(true, false)) {
val c = CommitmentsSpec.makeCommitments(31000000 msat, 702000000 msat, FeeratePerKw(2679 sat), 546 sat, isInitiator)
val add = UpdateAddHtlc(randomBytes32(), c.changes.remoteNextHtlcId, c.availableBalanceForReceive, randomBytes32(), CltvExpiry(f.currentBlockHeight), TestConstants.emptyOnionPacket, None, accountable = false, None)
- c.receiveAdd(add)
+ c.receiveAdd(add, f.currentBlockHeight)
}
}
@@ -432,7 +432,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with
val (_, cmdAdd) = makeCmdAdd(amount, randomKey().publicKey, f.currentBlockHeight)
c.sendAdd(cmdAdd, f.currentBlockHeight, TestConstants.Alice.nodeParams.channelConf, feeConfNoMismatch) match {
case Right((cc, _)) => c = cc
- case Left(e) => // we ignore failures (the HTLC amount probably exceeded availableBalanceForSend)
+ case Left(_) => // we ignore failures (the HTLC amount probably exceeded availableBalanceForSend)
}
}
if (c.availableBalanceForSend > 0.msat) {
@@ -460,14 +460,14 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with
for (_ <- 1 to t.pendingHtlcs) {
val amount = Random.nextInt(maxPendingHtlcAmount.toLong.toInt).msat.max(1 msat)
val add = UpdateAddHtlc(randomBytes32(), c.changes.remoteNextHtlcId, amount, randomBytes32(), CltvExpiry(f.currentBlockHeight), TestConstants.emptyOnionPacket, None, accountable = false, None)
- c.receiveAdd(add) match {
+ c.receiveAdd(add, f.currentBlockHeight) match {
case Right(cc) => c = cc
- case Left(e) => // we ignore failures (the HTLC amount probably exceeded availableBalanceForReceive)
+ case Left(_) => // we ignore failures (the HTLC amount probably exceeded availableBalanceForReceive)
}
}
if (c.availableBalanceForReceive > 0.msat) {
val add = UpdateAddHtlc(randomBytes32(), c.changes.remoteNextHtlcId, c.availableBalanceForReceive, randomBytes32(), CltvExpiry(f.currentBlockHeight), TestConstants.emptyOnionPacket, None, accountable = false, None)
- c.receiveAdd(add) match {
+ c.receiveAdd(add, f.currentBlockHeight) match {
case Right(_) => ()
case Left(e) => fail(s"$t -> $e")
}
### eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala
@@ -622,6 +622,19 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with
bob2blockchain.expectWatchTxConfirmed(tx.txid)
}
+ test("recv UpdateAddHtlc (invalid cltv_expiry)") { f =>
+ import f._
+ val tx = bob.signCommitTx()
+ bob ! UpdateAddHtlc(ByteVector32.Zeroes, 0, 150000 msat, randomBytes32(), CltvExpiry(500_000_000), TestConstants.emptyOnionPacket, None, accountable = false, None)
+ val error = bob2alice.expectMsgType[Error]
+ assert(new String(error.data.toArray) == ExpiryTooBig(channelId(bob), CltvExpiry(500_000_000), CltvExpiry(500_000_000), BlockHeight(TestConstants.defaultBlockHeight)).getMessage)
+ awaitCond(bob.stateName == CLOSING)
+ bob2blockchain.expectFinalTxPublished(tx.txid)
+ bob2blockchain.expectReplaceableTxPublished[ClaimLocalAnchorTx]
+ bob2blockchain.expectFinalTxPublished("local-main-delayed")
+ bob2blockchain.expectWatchTxConfirmed(tx.txid)
+ }
+
test("recv UpdateAddHtlc (value too small)") { f =>
import f._
val tx = bob.signCommitTx()Why this scored 60/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.