Reject incoming HTLCs with a high `cltv_expiry` (#3323)
What changed, and why it matters
This change tightens a safety limit on the Bitcoin Lightning Network node Eclair. Previously, Eclair already refused to send outgoing payments whose refund deadline (the 'cltv_expiry') was more than about two weeks in the future, because that would lock up funds for too long and make certain spam/jamming attacks easier. Now it also rejects incoming payments with similarly distant deadlines, but it does so gracefully by accepting the payment into the channel and then immediately failing it, rather than force-closing the channel. This is a defensive hardening patch, not a fix for an active exploit, and it reduces the risk of funds being locked up or the node being used for 'slow jamming' of the network.
Treat as a defensive hardening improvement. Review whether the chosen maxExpiryDelta (default 2 weeks) aligns with operational risk tolerance. Ensure downstream operators and integrators know that very long-expiry incoming payments will now be rejected. No emergency deployment is indicated by the commit alone.
Security signals we found
Adds upper-bound validation for cltv_expiry on incoming HTLCs
Prevents long fund lock-up in case of peer force-close
Mitigates slow jamming by capping maximum expiry delta
Avoids force-close by failing HTLC after acceptance instead of inside receiveAdd
Consistent with existing outgoing HTLC limit in sendAdd
Evidence from the diff
The commit extends the existing maxExpiryDelta check from outgoing HTLCs (sendAdd) to incoming HTLCs. In Commitments.scala, when validating CMD_ADD_HTLC, the code now computes the maximum of the outgoing cltv_expiry and the expiry of any upstream incoming HTLC (local, channel, or trampoline) and rejects if that maximum exceeds channelConf.maxExpiryDelta. In MultiPartHandler.scala, final incoming payments are also rejected if add.cltvExpiry >= maxExpiry. The rejection is done via a normal failure path (CMD_FAIL_HTLC / RES_ADD_FAILED) rather than in receiveAdd, so the channel stays open. Tests are added for local, channel-relayed, and trampoline-relayed cases.
Changed components
eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scalaeclair-core/src/main/scala/fr/acinq/eclair/payment/receive/MultiPartHandler.scalaLightning channel commitment validationIncoming payment handling / MultiPartHandlerInspect captured patch +52 / −9
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala
index 8dcaf02..6dd63a4 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala
@@ -902,10 +902,19 @@ case class Commitments(channelParams: ChannelParams,
if (cmd.cltvExpiry < minExpiry) {
return Left(ExpiryTooSmall(channelId, minimum = minExpiry, actual = cmd.cltvExpiry, blockHeight = currentHeight))
}
- // we don't want to use too high a refund timeout, because our funds will be locked during that time if the payment is never fulfilled
+ // We don't want to use too high a refund timeout, because our funds will be locked during that time if the payment
+ // is never fulfilled.
+ // We apply the same expiry limits to the corresponding incoming HTLCs: we do it here instead of inside receiveAdd,
+ // because returning a failure in receiveAdd would force-close the channel, whereas it is harmless to accept the
+ // HTLC and then fail it (like we do for dust exposure limits).
val maxExpiry = channelConf.maxExpiryDelta.toCltvExpiry(currentHeight)
- if (cmd.cltvExpiry >= maxExpiry) {
- return Left(ExpiryTooBig(channelId, maximum = maxExpiry, actual = cmd.cltvExpiry, blockHeight = currentHeight))
+ val expiry = cmd.origin.upstream match {
+ case _: Upstream.Local => cmd.cltvExpiry
+ case u: Upstream.Hot.Channel => Seq(cmd.cltvExpiry, u.expiryIn).max
+ case u: Upstream.Hot.Trampoline => (cmd.cltvExpiry +: u.received.map(_.expiryIn)).max
+ }
+ if (expiry >= maxExpiry) {
+ return Left(ExpiryTooBig(channelId, maximum = maxExpiry, actual = expiry, blockHeight = currentHeight))
}
// even if remote advertises support for 0 msat htlc, we limit ourselves to values strictly positive, hence the max(1 msat)
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/receive/MultiPartHandler.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/receive/MultiPartHandler.scala
index 6c9f271..5580aa6 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/receive/MultiPartHandler.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/receive/MultiPartHandler.scala
@@ -439,8 +439,12 @@ object MultiPartHandler {
private def validatePaymentCltv(nodeParams: NodeParams, add: UpdateAddHtlc, payload: FinalPayload)(implicit log: LoggingAdapter): Boolean = {
val minExpiry = nodeParams.channelConf.minFinalExpiryDelta.toCltvExpiry(nodeParams.currentBlockHeight)
+ val maxExpiry = nodeParams.channelConf.maxExpiryDelta.toCltvExpiry(nodeParams.currentBlockHeight)
if (add.cltvExpiry < minExpiry) {
- log.warning("received payment with expiry too small for amount={} totalAmount={}", add.amountMsat, payload.totalAmount)
+ log.warning("received payment with expiry too small for amount={} totalAmount={} (expiry={})", add.amountMsat, payload.totalAmount, add.cltvExpiry)
+ false
+ } else if (add.cltvExpiry >= maxExpiry) {
+ log.warning("received payment with expiry too big for amount={} totalAmount={} (expiry={})", add.amountMsat, payload.totalAmount, add.cltvExpiry)
false
} else {
true
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala
index a372127..095c31c 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala
@@ -49,6 +49,7 @@ import org.scalatest.funsuite.FixtureAnyFunSuiteLike
import org.scalatest.{Outcome, Tag}
import scodec.bits._
+import java.util.UUID
import scala.concurrent.duration._
/**
@@ -166,11 +167,25 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with
val sender = TestProbe()
val initialState = alice.stateData.asInstanceOf[DATA_NORMAL]
val maxAllowedExpiryDelta = alice.underlyingActor.nodeParams.channelConf.maxExpiryDelta
+ val validExpiry = CltvExpiryDelta(36).toCltvExpiry(currentBlockHeight)
val expiryTooBig = (maxAllowedExpiryDelta + 1).toCltvExpiry(currentBlockHeight)
- val add = CMD_ADD_HTLC(sender.ref, 500000000 msat, randomBytes32(), expiryTooBig, TestConstants.emptyOnionPacket, None, Reputation.Score.max(accountable = false), None, localOrigin(sender.ref))
- alice ! add
- val error = ExpiryTooBig(channelId(alice), maximum = maxAllowedExpiryDelta.toCltvExpiry(currentBlockHeight), actual = expiryTooBig, blockHeight = currentBlockHeight)
- sender.expectMsg(RES_ADD_FAILED(add, error, Some(initialState.channelUpdate)))
+ val incoming = UpdateAddHtlc(randomBytes32(), 7, 500_000_000 msat, randomBytes32(), validExpiry, TestConstants.emptyOnionPacket, None, accountable = true, None)
+ val cmd = CMD_ADD_HTLC(sender.ref, 500_000_000 msat, randomBytes32(), validExpiry, TestConstants.emptyOnionPacket, None, Reputation.Score.max(accountable = false), None, localOrigin(sender.ref))
+ val testCases = Seq(
+ // HTLC for which we're the sender.
+ cmd.copy(cltvExpiry = expiryTooBig, origin = Origin.Hot(sender.ref, Upstream.Local(UUID.randomUUID()))),
+ // HTLC relayed with a valid incoming HTLC expiry.
+ cmd.copy(cltvExpiry = expiryTooBig, origin = Origin.Hot(sender.ref, Upstream.Hot.Channel(incoming, 0 unixms, randomKey().publicKey, 0.5))),
+ // HTLC relayed with the incoming HTLC having a high expiry.
+ cmd.copy(cltvExpiry = validExpiry, origin = Origin.Hot(sender.ref, Upstream.Hot.Channel(incoming.copy(cltvExpiry = expiryTooBig), 0 unixms, randomKey().publicKey, 0.5))),
+ // HTLC relayed using trampoline, with one of the incoming HTLCs having a high expiry.
+ cmd.copy(cltvExpiry = validExpiry, origin = Origin.Hot(sender.ref, Upstream.Hot.Trampoline(Upstream.Hot.Channel(incoming, 0 unixms, randomKey().publicKey, 0.5) :: Upstream.Hot.Channel(incoming.copy(cltvExpiry = expiryTooBig), 0 unixms, randomKey().publicKey, 0.5) :: Nil))),
+ )
+ testCases.foreach(cmd => {
+ alice ! cmd
+ val error = ExpiryTooBig(channelId(alice), maximum = maxAllowedExpiryDelta.toCltvExpiry(currentBlockHeight), actual = expiryTooBig, blockHeight = currentBlockHeight)
+ sender.expectMsg(RES_ADD_FAILED(cmd, error, Some(initialState.channelUpdate)))
+ })
alice2bob.expectNoMessage(100 millis)
}
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/MultiPartHandlerSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/MultiPartHandlerSpec.scala
index 715a241..0aa4710 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/MultiPartHandlerSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/MultiPartHandlerSpec.scala
@@ -367,7 +367,7 @@ class MultiPartHandlerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike
assert(nodeParams.db.payments.getIncomingPayment(invoice.paymentHash).get.status == IncomingPaymentStatus.Pending)
}
- test("PaymentHandler should reject incoming multi-part payment with an invalid expiry") { f =>
+ test("PaymentHandler should reject incoming multi-part payment with an invalid expiry (expiry too small)") { f =>
import f._
sender.send(handlerWithMpp, ReceiveStandardPayment(sender.ref, Some(1000 msat), Left("multi-part invalid expiry")))
@@ -382,6 +382,21 @@ class MultiPartHandlerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike
assert(nodeParams.db.payments.getIncomingPayment(invoice.paymentHash).get.status == IncomingPaymentStatus.Pending)
}
+ test("PaymentHandler should reject incoming multi-part payment with an invalid expiry (expiry too big)") { f =>
+ import f._
+
+ sender.send(handlerWithMpp, ReceiveStandardPayment(sender.ref, Some(1000 msat), Left("multi-part invalid expiry")))
+ val invoice = sender.expectMsgType[Bolt11Invoice]
+ assert(invoice.features.hasFeature(BasicMultiPartPayment))
+
+ val highCltvExpiry = (nodeParams.channelConf.maxExpiryDelta + 1).toCltvExpiry(nodeParams.currentBlockHeight)
+ val add = UpdateAddHtlc(ByteVector32.One, 0, 800 msat, invoice.paymentHash, highCltvExpiry, TestConstants.emptyOnionPacket, None, accountable = false, None)
+ sender.send(handlerWithMpp, ReceivePacket(IncomingPaymentPacket.FinalPacket(add, FinalPayload.Standard.createPayload(add.amountMsat, 1000 msat, add.cltvExpiry, invoice.paymentSecret, invoice.paymentMetadata, upgradeAccountability = false), TimestampMilli.now()), randomKey().publicKey))
+ val cmd = register.expectMsgType[Register.Forward[CMD_FAIL_HTLC]].message
+ assert(cmd.reason == FailureReason.LocalFailure(IncorrectOrUnknownPaymentDetails(1000 msat, nodeParams.currentBlockHeight)))
+ assert(nodeParams.db.payments.getIncomingPayment(invoice.paymentHash).get.status == IncomingPaymentStatus.Pending)
+ }
+
test("PaymentHandler should reject incoming multi-part payment with an unknown payment hash") { f =>
import 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.