Skip to content

IllegalArgumentException: requirement failed: route cannot be empty in PaymentLifecycle.handleRemoteFail when using sendToRoute #3223

@DerEwige

Description

@DerEwige

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 sendToRoute with PredefinedChannelRoute for circular rebalancing.

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> via Route.stopAt:
    • require(hops.nonEmpty) in Route fails.
  • PaymentLifecycle.handleRemoteFail calls Route.stopAt(...) when handling a remote failure.

Given that:

  • This is triggered on a remote failure for a sendToRoute payment, and
  • The only way to get an empty Route there is if stopAt(0) (or equivalent) is called,

my hypothesis is:

  • A remote failure occurs on the first hop of the route.
  • handleRemoteFail tries to build a “route prefix up to failing hop” using route.stopAt(index).
  • When the failing index is 0, stopAt(0) yields an empty hop list, and constructing a Route with 0 hops fails the require(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 Failed payment 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

  • PaymentLifecycle throws IllegalArgumentException("route cannot be empty") from inside Route.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.handleRemoteFail should not call Route.stopAt(0), or
  • Route.stopAt (or the Route constructor used there) should gracefully handle the “empty hops” case when used purely for diagnostics (e.g. by not constructing a Route at all when the prefix would be empty).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions