xpay: don't crash on circular routehints.
What changed, and why it matters
This commit fixes a crash in Core Lightning's xpay plugin. When processing a BOLT11 invoice containing a circular route hint (where the start and end node are the same), the code would create an invalid self-loop channel and trigger a fatal assertion failure, killing the cln-askrene process. The patch rejects such self-loop channels at multiple layers so the payment plugin can skip them gracefully instead of crashing.
Apply the patch. The crash is remotely triggerable by presenting a crafted BOLT11 invoice with a circular routehint to xpay, so nodes running xpay should upgrade. No immediate workaround is documented; rejecting such invoices manually is impractical.
Security signals we found
Denial-of-service vector: malformed/circular BOLT11 routehint causes fatal assertion and process abort
Input validation gap: local channel creation allowed src == dst, breaking internal data structure invariant
Defense in depth: checks added at API boundary (askrene-create-channel), library layer (gossmap/layer), and plugin layer (xpay)
Crash-to-skip conversion: xpay now logs and ignores bad self-node route hints instead of propagating them
Evidence from the diff
The crash occurs in common/gossmap.c when a local channel is added with identical source and destination node IDs, violating an internal hash table invariant (nodeidx_htable_add assertion). The fix adds explicit source!=destination checks in: (1) gossmap_local_addchan with an assert; (2) askrene’s JSON create-channel handler returning JSONRPC2_INVALID_PARAMS; (3) layer_add_local_channel with an assert; and (4) xpay’s add_fake_channel, which logs and skips circular routehints. A previously xfail-marked test is now enabled to verify graceful handling.
Changed components
plugins/xpay/xpay.cplugins/askrene/askrene.cplugins/askrene/layer.cplugins/askrene/layer.hcommon/gossmap.ccommon/gossmap.hInspect captured patch +21 / −4
diff --git a/common/gossmap.c b/common/gossmap.c
index 78a885fc..38b57c19 100644
--- a/common/gossmap.c
+++ b/common/gossmap.c
@@ -1056,6 +1056,8 @@ bool gossmap_local_addchan(struct gossmap_localmods *localmods,
u64 off;
struct localmod mod;
+ assert(!node_id_eq(n1, n2));
+
/* Don't create duplicate channels. */
if (find_localmod(localmods, scid))
return false;
diff --git a/common/gossmap.h b/common/gossmap.h
index c8bfbf19..9b34be93 100644
--- a/common/gossmap.h
+++ b/common/gossmap.h
@@ -89,6 +89,7 @@ struct gossmap_localmods *gossmap_localmods_new(const tal_t *ctx);
/* Create a local-only channel; if this conflicts with a real channel when added,
* that will be used instead.
* Returns false (and does nothing) if scid was already in localmods.
+ * n1 must be different from n2.
*/
bool gossmap_local_addchan(struct gossmap_localmods *localmods,
const struct node_id *n1,
diff --git a/contrib/msggen/msggen/schema.json b/contrib/msggen/msggen/schema.json
index 2424bdab..846b7998 100644
--- a/contrib/msggen/msggen/schema.json
+++ b/contrib/msggen/msggen/schema.json
@@ -652,7 +652,7 @@
"destination": {
"type": "pubkey",
"description": [
- "The destination node id for the channel."
+ "The destination node id for the channel (must be different from source)."
]
},
"short_channel_id": {
diff --git a/doc/schemas/askrene-create-channel.json b/doc/schemas/askrene-create-channel.json
index a4256ae3..809fdafe 100644
--- a/doc/schemas/askrene-create-channel.json
+++ b/doc/schemas/askrene-create-channel.json
@@ -32,7 +32,7 @@
"destination": {
"type": "pubkey",
"description": [
- "The destination node id for the channel."
+ "The destination node id for the channel (must be different from source)."
]
},
"short_channel_id": {
diff --git a/plugins/askrene/askrene.c b/plugins/askrene/askrene.c
index 490e1ce4..736054eb 100644
--- a/plugins/askrene/askrene.c
+++ b/plugins/askrene/askrene.c
@@ -1066,6 +1066,10 @@ static struct command_result *json_askrene_create_channel(struct command *cmd,
plugin_log(cmd->plugin, LOG_TRACE, "%s called: %.*s", __func__,
json_tok_full_len(params), json_tok_full(buffer, params));
+ if (node_id_cmp(src, dst) == 0)
+ return command_fail(cmd, JSONRPC2_INVALID_PARAMS,
+ "source and destination must be different");
+
if (layer_find_local_channel(layer, *scid)) {
return command_fail(cmd, JSONRPC2_INVALID_PARAMS,
"channel already exists");
diff --git a/plugins/askrene/layer.c b/plugins/askrene/layer.c
index 610b3ce0..2c07ce77 100644
--- a/plugins/askrene/layer.c
+++ b/plugins/askrene/layer.c
@@ -234,6 +234,7 @@ static struct local_channel *add_local_channel(struct layer *layer,
lc->n1 = *n1;
lc->n2 = *n2;
} else {
+ assert(!node_id_eq(n1, n2));
lc->n1 = *n2;
lc->n2 = *n1;
}
diff --git a/plugins/askrene/layer.h b/plugins/askrene/layer.h
index 547ed8da..f70e39f0 100644
--- a/plugins/askrene/layer.h
+++ b/plugins/askrene/layer.h
@@ -52,7 +52,7 @@ bool layer_check_local_channel(const struct local_channel *lc,
const struct node_id *n2,
struct amount_msat capacity);
-/* Add a local channel to a layer! */
+/* Add a local channel to a layer: src must not be equal to dst!*/
void layer_add_local_channel(struct layer *layer,
const struct node_id *src,
const struct node_id *dst,
diff --git a/plugins/xpay/xpay.c b/plugins/xpay/xpay.c
index 8510c596..277f1a55 100644
--- a/plugins/xpay/xpay.c
+++ b/plugins/xpay/xpay.c
@@ -1922,6 +1922,16 @@ static void add_fake_channel(struct command *aux_cmd,
struct out_req *req;
struct short_channel_id_dir scidd;
+ /* We're not allowed to send these to askrene-create-channel,
+ * so catch them now */
+ if (node_id_eq(src, dst)) {
+ payment_log(payment, LOG_UNUSUAL,
+ "Invoice gave bad self-node route %s->%s",
+ fmt_node_id(tmpctx, src),
+ fmt_node_id(tmpctx, dst));
+ return;
+ }
+
scidd.scid = scid;
scidd.dir = node_id_idx(src, dst);
payment_log(payment, LOG_DBG,
diff --git a/tests/test_xpay.py b/tests/test_xpay.py
index 99d91034..c6f8af0e 100644
--- a/tests/test_xpay.py
+++ b/tests/test_xpay.py
@@ -975,7 +975,6 @@ def test_xpay_offer(node_factory):
l1.rpc.xpay(offer2, 5000)
-@pytest.mark.xfail(strict=True)
def test_xpay_circular_routehint(node_factory):
"""Test that xpay gracefully skips a circular bolt11 routehint (src == dst)."""
l1, l2 = node_factory.line_graph(2)
Why this scored 45/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.