-
Notifications
You must be signed in to change notification settings - Fork 276
Description
Note
This issue description was drafted with the help of an LLM (ChatGPT), based on my logs and code, but the behavior and stack trace are from a real node.
Description
I’m running a rebalancing plugin that uses sendToRoute with PredefinedChannelRoute.
Under some conditions I’m seeing an unhandled IllegalArgumentException coming from eclair’s payment lifecycle:
2025-12-06 16:09:58,058 ERROR a.a.OneForOneStrategy - requirement failed: route cannot be emptyjava.lang.IllegalArgumentException: requirement failed: route cannot be empty
at scala.Predef$.require(Predef.scala:337)
at fr.acinq.eclair.router.Router$Route.<init>(Router.scala:678)
at fr.acinq.eclair.router.Router$Route.stopAt(Router.scala:708)
at fr.acinq.eclair.payment.send.PaymentLifecycle.fr$acinq$eclair$payment$send$PaymentLifecycle$$handleRemoteFail(PaymentLifecycle.scala:213)
at fr.acinq.eclair.payment.send.PaymentLifecycle$$anonfun$4.applyOrElse(PaymentLifecycle.scala:141)
at fr.acinq.eclair.payment.send.PaymentLifecycle$$anonfun$4.applyOrElse(PaymentLifecycle.scala:111)
at scala.runtime.AbstractPartialFunction.apply(AbstractPartialFunction.scala:35)
at akka.actor.FSM.processEvent(FSM.scala:851)
at akka.actor.FSM.processEvent$(FSM.scala:848)
at fr.acinq.eclair.payment.send.PaymentLifecycle.processEvent(PaymentLifecycle.scala:46)
at akka.actor.FSM.akka$actor$FSM$$processMsg(FSM.scala:845)
at akka.actor.FSM$$anonfun$receive$1.applyOrElse(FSM.scala:840)
at akka.actor.Actor.aroundReceive(Actor.scala:537)
at akka.actor.Actor.aroundReceive$(Actor.scala:535)
at fr.acinq.eclair.payment.send.PaymentLifecycle.fr$acinq$eclair$FSMDiagnosticActorLogging$$super$aroundReceive(PaymentLifecycle.scala:46)
at fr.acinq.eclair.FSMDiagnosticActorLogging.aroundReceive(FSMDiagnosticActorLogging.scala:38)
at fr.acinq.eclair.FSMDiagnosticActorLogging.aroundReceive$(FSMDiagnosticActorLogging.scala:36)
at fr.acinq.eclair.payment.send.PaymentLifecycle.aroundReceive(PaymentLifecycle.scala:46)
at akka.actor.ActorCell.receiveMessage(ActorCell.scala:579)
at akka.actor.ActorCell.invoke(ActorCell.scala:547)
at akka.dispatch.Mailbox.processMailbox(Mailbox.scala:270)
at akka.dispatch.Mailbox.run(Mailbox.scala:231)
at akka.dispatch.Mailbox.exec(Mailbox.scala:243)
at java.base/java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:511)
So it’s not a routing failure being reported back to the caller – it’s an internal assertion that aborts the actor.
Environment
- eclair version: 0.13.1
- Using a custom Java plugin that:
- Calls
sendToRoutewithPredefinedChannelRoutefor circular rebalancing.
- Calls
How I’m calling sendToRoute
From the plugin, I construct a PredefinedChannelRoute and call sendToRoute like this (simplified):
PredefinedChannelRoute route = new PredefinedChannelRoute(
AmountMsat,
MyNodeId,
shortChannelIds, // non-empty Seq<ShortChannelId>, includes first+last hop
Option.apply(MaxFees)
);
if (!route.isEmpty()) {
return eclair.sendToRoute(
Option.apply(AmountMsat),
Option.apply(null),
Option.apply(null),
invoice,
route,
myTimeout
).result(myTimeout.duration(), null);
}shortChannelIds is always non-empty when I call sendToRoute (I explicitly guard against empty / length-1 routes).
The payments are rebalancing loops: from my node, through a set of channels, and back to me.
What seems to be happening
Looking at the stack trace:
- The exception comes from
Router.Route.<init>viaRoute.stopAt:require(hops.nonEmpty)inRoutefails.
PaymentLifecycle.handleRemoteFailcallsRoute.stopAt(...)when handling a remote failure.
Given that:
- This is triggered on a remote failure for a
sendToRoutepayment, and - The only way to get an empty
Routethere is ifstopAt(0)(or equivalent) is called,
my hypothesis is:
- A remote failure occurs on the first hop of the route.
handleRemoteFailtries to build a “route prefix up to failing hop” usingroute.stopAt(index).- When the failing index is
0,stopAt(0)yields an empty hop list, and constructing aRoutewith 0 hops fails therequire(hops.nonEmpty)precondition.
So the issue is not that I’m passing an empty route to eclair – it’s that, on failure at hop 0, PaymentLifecycle ends up trying to create a diagnostic Route with zero hops.
When it happens
It’s rarer under normal operation, but my plugin stresses sendToRoute a lot for rebalancing and probes channels that can be:
- disabled,
- flappy,
- or otherwise quickly failing on the very first hop.
That likely increases the chance of “remote failure at first hop” and triggers this edge case.
Expected behavior
On a remote failure at the first hop for a sendToRoute payment, I’d expect eclair to:
- Handle the error without throwing an internal
IllegalArgumentException, and - Return a regular
Failedpayment status to the caller.
In other words: an empty “prefix route” for diagnostics should be handled gracefully, without constructing a Route with zero hops.
Actual behavior
PaymentLifecyclethrowsIllegalArgumentException("route cannot be empty")from insideRoute.stopAt, which crashes the payment FSM actor for that payment.
Possible fix direction
From reading the stack trace and the involved code, it seems that either:
PaymentLifecycle.handleRemoteFailshould not callRoute.stopAt(0), orRoute.stopAt(or theRouteconstructor used there) should gracefully handle the “empty hops” case when used purely for diagnostics (e.g. by not constructing aRouteat all when the prefix would be empty).