Always count local CLTV delta in route finding (#3174)
What changed, and why it matters
This change fixes how Eclair counts time-lock delays when finding payment routes through the Lightning network. Previously, when calculating a route starting from the user's own node, the software ignored the delay on the first local channel. This could lead to choosing routes that look cheaper or faster but actually require longer lock-up times than expected, potentially causing payments to fail or funds to be locked longer than the user intended. The fix now always includes that first-channel delay in route calculations.
Review related route-finding and fee-accounting logic for similar special cases where local-channel parameters are skipped. Confirm that downstream payment construction uses the corrected total CLTV. No urgent deployment action is indicated beyond normal patch management.
Security signals we found
Route-finding cost accounting corrected for local CLTV delta
Special-case bypass removed that zeroed first-hop time-lock cost
Risk-cost formula now uses actual edge CLTV for local channel
Test scenario adjusted to assert local CLTV is included
Evidence from the diff
In Graph.scala, two functions that compute edge weights for pathfinding previously set the CLTV delta to zero for the local (first) edge when includeLocalChannelCost was false. The patch removes that special case so edge.params.cltvExpiryDelta is always added to totalCltv and used in the risk-cost calculation. The test is updated to make the route-choice scenario clearer: the alternative path’s fees are raised and the CLTV weighting factor is set to zero, demonstrating that the local CLTV delta is now counted even when fees are not. This is a correctness fix in route-cost accounting, not a cryptographic or remote-exploitable vulnerability.
Changed components
eclair-core/src/main/scala/fr/acinq/eclair/router/Graph.scalaeclair-core/src/test/scala/fr/acinq/eclair/router/RouteCalculationSpec.scalaInspect captured patch +11 / −13
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/router/Graph.scala b/eclair-core/src/main/scala/fr/acinq/eclair/router/Graph.scala
index 12ca028..c650d0a 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/router/Graph.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/router/Graph.scala
@@ -21,7 +21,7 @@ import fr.acinq.bitcoin.scalacompat.{Btc, MilliBtc, Satoshi}
import fr.acinq.eclair._
import fr.acinq.eclair.payment.Invoice
import fr.acinq.eclair.payment.relay.Relayer.RelayFees
-import fr.acinq.eclair.router.Graph.GraphStructure.{DirectedGraph, GraphEdge}
+import fr.acinq.eclair.router.Graph.GraphStructure.GraphEdge
import fr.acinq.eclair.router.Router._
import fr.acinq.eclair.wire.protocol.{ChannelUpdate, NodeAnnouncement}
@@ -102,8 +102,7 @@ object Graph {
val totalAmount = if (edge.desc.a == sender && !includeLocalChannelCost) prev.amount else addEdgeFees(edge, prev.amount)
val fee = totalAmount - prev.amount
val totalFees = prev.fees + fee
- val cltv = if (edge.desc.a == sender && !includeLocalChannelCost) CltvExpiryDelta(0) else edge.params.cltvExpiryDelta
- val totalCltv = prev.cltv + cltv
+ val totalCltv = prev.cltv + edge.params.cltvExpiryDelta
val hopCost = if (edge.desc.a == sender) 0 msat else nodeFee(hopFees, prev.amount)
import RoutingHeuristics._
@@ -153,8 +152,7 @@ object Graph {
val totalAmount = if (edge.desc.a == sender && !includeLocalChannelCost) prev.amount else addEdgeFees(edge, prev.amount)
val fee = totalAmount - prev.amount
val totalFees = prev.fees + fee
- val cltv = if (edge.desc.a == sender && !includeLocalChannelCost) CltvExpiryDelta(0) else edge.params.cltvExpiryDelta
- val totalCltv = prev.cltv + cltv
+ val totalCltv = prev.cltv + edge.params.cltvExpiryDelta
val hopCost = nodeFee(hopFees, prev.amount)
val totalHopsCost = prev.virtualFees + hopCost
// If we know the balance of the channel, then we will check separately that it can relay the payment.
@@ -172,7 +170,7 @@ object Graph {
val totalSuccessProbability = prev.successProbability * successProbability
val failureCost = nodeFee(failureFees, totalAmount)
val richWeight = if (useLogProbability) {
- val riskCost = totalAmount.toLong * cltv.toInt * lockedFundsRisk
+ val riskCost = totalAmount.toLong * edge.params.cltvExpiryDelta.toInt * lockedFundsRisk
val weight = prev.weight + fee.toLong + hopCost.toLong + riskCost - failureCost.toLong * math.log(successProbability)
PaymentPathWeight(totalAmount, prev.length + 1, totalCltv, totalSuccessProbability, totalFees, totalHopsCost, weight)
} else {
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/router/RouteCalculationSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/router/RouteCalculationSpec.scala
index c16e62f..c80c06b 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/router/RouteCalculationSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/router/RouteCalculationSpec.scala
@@ -882,18 +882,18 @@ class RouteCalculationSpec extends AnyFunSuite with ParallelTestExecution {
// A -> E -> F -> D is more expensive but has a total CLTV < 2016
val g = GraphWithBalanceEstimates(DirectedGraph(List(
makeEdge(1, a, b, feeBase = 1 msat, 0, minHtlc = 0 msat, maxHtlc = None, cltvDelta = CltvExpiryDelta(144)),
- makeEdge(4, a, e, feeBase = 1 msat, 0, minHtlc = 0 msat, maxHtlc = None, cltvDelta = CltvExpiryDelta(144)),
+ makeEdge(4, a, e, feeBase = 100 msat, 0, minHtlc = 0 msat, maxHtlc = None, cltvDelta = CltvExpiryDelta(144)),
makeEdge(2, b, c, feeBase = 1 msat, 0, minHtlc = 0 msat, maxHtlc = None, cltvDelta = CltvExpiryDelta(1000)),
makeEdge(3, c, d, feeBase = 1 msat, 0, minHtlc = 0 msat, maxHtlc = None, cltvDelta = CltvExpiryDelta(900)),
- makeEdge(5, e, f, feeBase = 1 msat, 0, minHtlc = 0 msat, maxHtlc = None, cltvDelta = CltvExpiryDelta(144)),
- makeEdge(6, f, d, feeBase = 1 msat, 0, minHtlc = 0 msat, maxHtlc = None, cltvDelta = CltvExpiryDelta(144))
+ makeEdge(5, e, f, feeBase = 100 msat, 0, minHtlc = 0 msat, maxHtlc = None, cltvDelta = CltvExpiryDelta(144)),
+ makeEdge(6, f, d, feeBase = 100 msat, 0, minHtlc = 0 msat, maxHtlc = None, cltvDelta = CltvExpiryDelta(144))
)), 1 day)
val Success(routeScoreOptimized :: Nil) = findRoute(g, a, d, DEFAULT_AMOUNT_MSAT / 2, DEFAULT_MAX_FEE, numRoutes = 1, routeParams = DEFAULT_ROUTE_PARAMS.copy(heuristics = PaymentWeightRatios(
- baseFactor = 0.01,
- ageFactor = 0.33,
- cltvDeltaFactor = 0.33,
- capacityFactor = 0.33,
+ baseFactor = 0.2,
+ ageFactor = 0.4,
+ cltvDeltaFactor = 0,
+ capacityFactor = 0.4,
hopFees = RelayFees(0 msat, 0),
)), currentBlockHeight = BlockHeight(400000))
Why this scored 49/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.