eclair icon indicating copy to clipboard operation
eclair copied to clipboard

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

Open DerEwige opened this issue 1 month ago • 3 comments

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).

DerEwige avatar Dec 06 '25 15:12 DerEwige

Please, do not use ChatGPT to write bug reports. Its analysis is just a very verbose reformulation of the stack trace and some nonsensical suggestion (Route.stopAt(index) is not at all how this works, stopAt expects a node id). Just give us the information you have, we can run ChatGPT ourselves if we want this nonsense.

It seems that your node failed the payment, possibly because of the bug in the way you create these circular payments. There is also a bug in eclair which is that it believes that the error comes from the first node of the route (your node) even though it came from the last node of the route (also your node), this can only happen with circular payments (which we discourage) and if we fail the HTLC as the recipient (which we shouldn't unless another node in the route tries to cheat).

thomash-acinq avatar Dec 09 '25 13:12 thomash-acinq

I've only used LLM for this because in the 1+ year since I first encountered the issue, I could never find the cause for it.

The exception pops up about maybe 5 to 10 times a day. I generate about 200k routes per day. So it only happens very rarely, but still on a regular basis.

This is the code that creates the route

I use the PredefinedChannelRoute contructor and then directly sendToRoute with a simple check to make sure the route I got was not empty.

	private SendPaymentToRouteResponse sendPayment(Bolt11Invoice Invoice, Seq<ShortChannelId> shortChannelIds,
			MilliSatoshi AmountMsat, MilliSatoshi MaxFees) throws Throwable {
		PredefinedChannelRoute Route = new PredefinedChannelRoute(AmountMsat, MyNodeId, shortChannelIds,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));
		}
		return null;
	}

If you see any possible issue with my code, please let me know.

DerEwige avatar Dec 09 '25 14:12 DerEwige

It could be that another node in the route is cheating (or buggy in a way that makes the HTLC invalid), your node would detect the cheating and fail the HTLC. Regardless of why the HTLC is failed by your node, this PR should handle it properly: https://github.com/ACINQ/eclair/pull/3224

thomash-acinq avatar Dec 09 '25 15:12 thomash-acinq