askrene: give clearer error codes.
What changed, and why it matters
This commit improves the error messages returned by Core Lightning's routing plugin (askrene) when a payment cannot be routed. It adds two new, more specific error codes: one for 'not enough local funds' and one for 'destination cannot receive this amount.' There is no indication this fixes an active security vulnerability; it is a user-experience and diagnostics improvement.
Treat as a routine improvement commit. No urgent security action is required. Reviewers may verify that the new error codes are documented consistently and that tests cover both new codes.
Security signals we found
New error codes improve API observability but do not change access control or trust boundaries
No buffer overflow, use-after-free, or input-validation changes observed
No cryptographic or secret-handling changes observed
No privilege escalation or remote-code-execution vectors introduced
Change is defensive/diagnostic in nature
Evidence from the diff
The patch introduces PAY_INSUFFICIENT_FUNDS (215) and PAY_DESTINATION_INSUFFICIENT_CAPACITY (220) error codes and updates the askrene failure-explanation logic to distinguish between total capacity shortfalls (local or destination) and other routing failures. The explain_failure() function now tracks the most constraining layers and returns a specific ecode to callers. Tests are updated and an xfail marker is removed. No memory-safety, cryptographic, or authorization changes are present.
Changed components
plugins/askrene/child/explain_failure.cplugins/askrene/child/explain_failure.hplugins/askrene/child/mcf.ccommon/jsonrpc_errors.hdoc/schemas/getroutes.jsoncontrib/msggen/msggen/schema.jsontests/test_askrene.pytests/test_pay.pyInspect captured patch +118 / −27
diff --git a/common/jsonrpc_errors.h b/common/jsonrpc_errors.h
index 4108701e..ac29cb66 100644
--- a/common/jsonrpc_errors.h
+++ b/common/jsonrpc_errors.h
@@ -52,6 +52,7 @@ enum jsonrpc_errcode {
PAY_USER_ERROR = 217,
PAY_INJECTPAYMENTONION_FAILED = 218,
PAY_INJECTPAYMENTONION_ALREADY_PAID = 219,
+ PAY_DESTINATION_INSUFFICIENT_CAPACITY = 220,
/* `fundchannel` or `withdraw` errors */
FUND_MAX_EXCEEDED = 300,
diff --git a/contrib/msggen/msggen/schema.json b/contrib/msggen/msggen/schema.json
index 2ab6ab92..a99e1fd7 100644
--- a/contrib/msggen/msggen/schema.json
+++ b/contrib/msggen/msggen/schema.json
@@ -17445,7 +17445,9 @@
"",
"- -1: Catchall nonspecific error.",
"- 205: Unable to find a route.",
- "- 206: Route too expensive. Either the max_delay or maxfee_msat was exceeded."
+ "- 206: Route too expensive. Either the max_delay or maxfee_msat was exceeded.",
+ "- 215: We don't have enough funds in all our channels to pay this.",
+ "- 220: We can't find sufficient capacity into the destination."
],
"author": [
"[lagrang3@protonmail.com](mailto:lagrang3@protonmail.com) wrote the minimum-cost-flow solver, Rusty Russell [rusty@rustcorp.com.au](mailto:rusty@rustcorp.com.au) wrote the API and this documentation."
diff --git a/doc/schemas/getroutes.json b/doc/schemas/getroutes.json
index 5b02d8af..bb9c1833 100644
--- a/doc/schemas/getroutes.json
+++ b/doc/schemas/getroutes.json
@@ -236,7 +236,9 @@
"",
"- -1: Catchall nonspecific error.",
"- 205: Unable to find a route.",
- "- 206: Route too expensive. Either the max_delay or maxfee_msat was exceeded."
+ "- 206: Route too expensive. Either the max_delay or maxfee_msat was exceeded.",
+ "- 215: We don't have enough funds in all our channels to pay this.",
+ "- 220: We can't find sufficient capacity into the destination."
],
"author": [
"[lagrang3@protonmail.com](mailto:lagrang3@protonmail.com) wrote the minimum-cost-flow solver, Rusty Russell [rusty@rustcorp.com.au](mailto:rusty@rustcorp.com.au) wrote the API and this documentation."
diff --git a/plugins/askrene/child/explain_failure.c b/plugins/askrene/child/explain_failure.c
index 364e41d0..a11179d1 100644
--- a/plugins/askrene/child/explain_failure.c
+++ b/plugins/askrene/child/explain_failure.c
@@ -83,7 +83,7 @@ struct stat {
};
struct node_stats {
- struct stat total, gossip_known, enabled;
+ struct stat total, gossip_known, max_capacity_known, enabled;
};
enum node_direction {
@@ -99,42 +99,104 @@ static void add_stat(struct stat *stat,
abort();
}
-static void node_stats(const struct route_query *rq,
- const struct gossmap_node *node,
- enum node_direction node_direction,
- struct node_stats *stats)
+static bool layer_in_array(const struct layer **layers,
+ const struct layer *layer)
{
+ for (size_t i = 0; i < tal_count(layers); i++) {
+ if (layers[i] == layer)
+ return true;
+ }
+ return false;
+}
+
+/* Returns most constraining layers, if any */
+static const struct layer **node_stats(const tal_t *ctx,
+ const struct route_query *rq,
+ const struct gossmap_node *node,
+ enum node_direction node_direction,
+ const struct layer **layers,
+ struct node_stats *stats)
+{
+ const struct layer **most_constraining = tal_arr(ctx, const struct layer *, 0);
+
memset(stats, 0, sizeof(*stats));
for (size_t i = 0; i < node->num_chans; i++) {
- int dir;
+ struct short_channel_id_dir scidd;
struct gossmap_chan *c;
- struct amount_msat cap_msat;
+ struct amount_msat min, max, cap_msat;
+ const struct layer *constrainer;
- c = gossmap_nth_chan(rq->gossmap, node, i, &dir);
+ c = gossmap_nth_chan(rq->gossmap, node, i, &scidd.dir);
+ scidd.scid = gossmap_chan_scid(rq->gossmap, c);
cap_msat = gossmap_chan_get_capacity(rq->gossmap, c);
if (node_direction == INTO_NODE)
- dir = !dir;
+ scidd.dir = !scidd.dir;
add_stat(&stats->total, cap_msat);
- if (gossmap_chan_set(c, dir))
+ if (gossmap_chan_set(c, scidd.dir)) {
add_stat(&stats->gossip_known, cap_msat);
- if (c->half[dir].enabled)
- add_stat(&stats->enabled, cap_msat);
+ if (c->half[scidd.dir].enabled)
+ add_stat(&stats->enabled, cap_msat);
+ }
+
+ min = AMOUNT_MSAT(0);
+ max = cap_msat;
+ constrainer = NULL;
+ for (size_t j = 0; j < tal_count(layers); j++) {
+ struct amount_msat old_max = max;
+ layer_apply_constraints(layers[j], &scidd, &min, &max);
+ if (!amount_msat_eq(max, old_max))
+ constrainer = layers[j];
+ }
+ if (constrainer && !layer_in_array(most_constraining, constrainer))
+ tal_arr_expand(&most_constraining, constrainer);
+ add_stat(&stats->max_capacity_known, max);
}
+ return most_constraining;
}
+static const char *format_layer_names(const tal_t *ctx, const struct layer **layers)
+{
+ char *ret;
+
+ /* Shouldn't happen! */
+ if (tal_count(layers) == 0)
+ return "UNKNOWN";
+ if (tal_count(layers) == 1)
+ return layer_name(layers[0]);
+
+ ret = tal_strdup(ctx, "layers");
+
+ for (size_t i = 0; i < tal_count(layers); i++) {
+ const char *prefix;
+ if (i == 0)
+ prefix = " ";
+ else if (i + 1 == tal_count(layers))
+ prefix = " and ";
+ else
+ prefix = ", ";
+ tal_append_fmt(&ret, "%s%s", prefix, layer_name(layers[i]));
+ }
+ return ret;
+}
+
+/* On non-NULL return, *total_capacity_failure is true if there's not
+ * sufficient capacity at all to make this payment */
static const char *check_capacity(const tal_t *ctx,
const struct route_query *rq,
const struct gossmap_node *node,
enum node_direction node_direction,
struct amount_msat amount,
- const char *name)
+ const char *name,
+ bool *total_capacity_failure)
{
struct node_stats stats;
+ const struct layer **most_constraining;
- node_stats(rq, node, node_direction, &stats);
+ most_constraining = node_stats(tmpctx, rq, node, node_direction, rq->layers, &stats);
if (amount_msat_greater(amount, stats.total.capacity)) {
+ *total_capacity_failure = true;
return child_log(ctx, LOG_DBG,
NO_USABLE_PATHS_STRING
" Total %s capacity is only %s"
@@ -143,7 +205,19 @@ static const char *check_capacity(const tal_t *ctx,
fmt_amount_msat(tmpctx, stats.total.capacity),
stats.total.num_channels);
}
+ if (amount_msat_greater(amount, stats.max_capacity_known.capacity)) {
+ *total_capacity_failure = true;
+ return child_log(ctx, LOG_DBG,
+ NO_USABLE_PATHS_STRING
+ " We know from %s that %s has maximum capacity %s"
+ " (in %zu channels).",
+ format_layer_names(tmpctx, most_constraining),
+ name,
+ fmt_amount_msat(tmpctx, stats.max_capacity_known.capacity),
+ stats.max_capacity_known.num_channels);
+ }
if (amount_msat_greater(amount, stats.gossip_known.capacity)) {
+ *total_capacity_failure = false;
return child_log(ctx, LOG_DBG,
NO_USABLE_PATHS_STRING
" Missing gossip for %s: only known %zu/%zu channels, leaving capacity only %s of %s.",
@@ -154,6 +228,7 @@ static const char *check_capacity(const tal_t *ctx,
fmt_amount_msat(tmpctx, stats.total.capacity));
}
if (amount_msat_greater(amount, stats.enabled.capacity)) {
+ *total_capacity_failure = false;
/* Common case: one channel, disabled */
if (stats.enabled.num_channels == 0) {
return child_log(ctx, LOG_DBG,
@@ -219,7 +294,8 @@ const char *explain_failure(const tal_t *ctx,
const struct route_query *rq,
const struct gossmap_node *srcnode,
const struct gossmap_node *dstnode,
- struct amount_msat amount)
+ struct amount_msat amount,
+ enum jsonrpc_errcode *ecode)
{
const struct route_hop *hops;
const struct dijkstra *dij;
@@ -230,18 +306,28 @@ const char *explain_failure(const tal_t *ctx,
struct gossmap_chan *c;
struct amount_msat rolling_amount;
struct amount_msat *path_amount;
+ bool total_capacity_failure;
+
+ /* The default answer */
+ *ecode = PAY_ROUTE_NOT_FOUND;
/* Do we have enough funds? */
cap_check = check_capacity(ctx, rq, srcnode, OUT_OF_NODE,
- amount, "source");
- if (cap_check)
+ amount, "source", &total_capacity_failure);
+ if (cap_check) {
+ if (total_capacity_failure)
+ *ecode = PAY_INSUFFICIENT_FUNDS;
return cap_check;
+ }
/* Does destination have enough capacity? */
cap_check = check_capacity(ctx, rq, dstnode, INTO_NODE,
- amount, "destination");
- if (cap_check)
+ amount, "destination", &total_capacity_failure);
+ if (cap_check) {
+ if (total_capacity_failure)
+ *ecode = PAY_DESTINATION_INSUFFICIENT_CAPACITY;
return cap_check;
+ }
/* OK, fall back to telling them why didn't shortest path
* work. This covers the "but I have a direct channel!"
diff --git a/plugins/askrene/child/explain_failure.h b/plugins/askrene/child/explain_failure.h
index 2914b9ee..5a2b121d 100644
--- a/plugins/askrene/child/explain_failure.h
+++ b/plugins/askrene/child/explain_failure.h
@@ -2,6 +2,7 @@
#define LIGHTNING_PLUGINS_ASKRENE_CHILD_EXPLAIN_FAILURE_H
#include "config.h"
#include <common/amount.h>
+#include <common/jsonrpc_errors.h>
struct route_query;
struct gossmap_node;
@@ -11,6 +12,7 @@ const char *explain_failure(const tal_t *ctx,
const struct route_query *rq,
const struct gossmap_node *srcnode,
const struct gossmap_node *dstnode,
- struct amount_msat amount);
+ struct amount_msat amount,
+ enum jsonrpc_errcode *ecode);
#endif /* LIGHTNING_PLUGINS_ASKRENE_CHILD_EXPLAIN_FAILURE_H */
diff --git a/plugins/askrene/child/mcf.c b/plugins/askrene/child/mcf.c
index dde97f02..3c455b85 100644
--- a/plugins/askrene/child/mcf.c
+++ b/plugins/askrene/child/mcf.c
@@ -1401,8 +1401,7 @@ linear_routes(const tal_t *ctx, struct route_query *rq,
if (!new_flows) {
error_message = explain_failure(
- ctx, rq, srcnode, dstnode, amount_to_deliver);
- *ecode = PAY_ROUTE_NOT_FOUND;
+ ctx, rq, srcnode, dstnode, amount_to_deliver, ecode);
goto fail;
}
diff --git a/tests/test_askrene.py b/tests/test_askrene.py
index 005a36f2..daf93bd9 100644
--- a/tests/test_askrene.py
+++ b/tests/test_askrene.py
@@ -2741,7 +2741,6 @@ def test_bad_user_entries(node_factory):
)
-@pytest.mark.xfail(strict=True)
def test_explain_source_dest_failures(node_factory, bitcoind):
"""askrene should give intelligent failure reasons when source or destination don't have
capacity"""
diff --git a/tests/test_pay.py b/tests/test_pay.py
index 3ca2493d..b211a1c0 100644
--- a/tests/test_pay.py
+++ b/tests/test_pay.py
@@ -4894,8 +4894,8 @@ def test_fetchinvoice_autoconnect(node_factory, bitcoind):
l3.rpc.disconnect(l2.info['id'])
invreq = l2.rpc.call('invoicerequest', {'amount': '2msat',
'description': 'simple test'})
- # Ofc l2 can't actually pay it!
- with pytest.raises(RpcError, match='pay attempt failed: "Failed: There is no connection between source and destination at all"'):
+ # Ofc l3 can't actually pay it!
+ with pytest.raises(RpcError, match=r'pay attempt failed: "Failed: We could not find a usable set of paths. We know from auto.localchans that source has maximum capacity 0msat \(in 1 channels\)."'):
l3.rpc.call('sendinvoice', {'invreq': invreq['bolt12'], 'label': 'payme!'})
assert l3.rpc.listpeers(l2.info['id'])['peers'] != []
Why this scored 19/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.