From 841f78c889b311952c0c74ca4e320166efe74243 Mon Sep 17 00:00:00 2001 From: Rusty Russell Date: Fri, 29 May 2026 17:25:40 +0930 Subject: [PATCH 1/3] xpay: print rejected currency correctly. Reported-by: Won Hoi Kim and Ahmad Elmoursi Signed-off-by: Rusty Russell --- plugins/xpay/xpay.c | 6 ++++-- tests/test_xpay.py | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/plugins/xpay/xpay.c b/plugins/xpay/xpay.c index 6a0ff3093876..8510c59652bd 100644 --- a/plugins/xpay/xpay.c +++ b/plugins/xpay/xpay.c @@ -2153,7 +2153,8 @@ static struct command_result *check_offer_payable(struct command *cmd, /* We will only one-shot if we know amount! (FIXME: Convert!) */ if (b12offer->offer_currency) return command_fail(cmd, JSONRPC2_INVALID_PARAMS, - "Cannot pay offer in different currency %s", + "Cannot pay offer in different currency %.*s", + (int)tal_bytelen(b12offer->offer_currency), b12offer->offer_currency); if (b12offer->offer_amount) { if (msat && !amount_msat_eq(amount_msat(*b12offer->offer_amount), *msat)) { @@ -2188,7 +2189,8 @@ check_offer_sendamount_payable(struct command *cmd, const char *offerstr) /* FIXME: add currency support */ if (b12offer->offer_currency) return command_fail(cmd, JSONRPC2_INVALID_PARAMS, - "Cannot pay offer in different currency %s", + "Cannot pay offer in different currency %.*s", + (int)tal_bytelen(b12offer->offer_currency), b12offer->offer_currency); /* Can only be applied to *any amount* offers. */ if (b12offer->offer_amount) diff --git a/tests/test_xpay.py b/tests/test_xpay.py index c419589ecc9c..0f8475bc299d 100644 --- a/tests/test_xpay.py +++ b/tests/test_xpay.py @@ -975,6 +975,21 @@ def test_xpay_offer(node_factory): l1.rpc.xpay(offer2, 5000) +def test_xpay_currency_offer(node_factory): + """Test that xpay and sendamount correctly report the currency name when rejecting non-msat offers.""" + plugin = Path(__file__).parent / "plugins" / "currencyUSDAUD5000.py" + l1, l2 = node_factory.line_graph(2, wait_for_announce=True, + opts=[{}, {'plugin': str(plugin)}]) + + offerusd = l2.rpc.offer('10USD', 'USD test')['bolt12'] + + with pytest.raises(RpcError, match=r"Cannot pay offer in different currency USD"): + l1.rpc.xpay(offerusd) + + with pytest.raises(RpcError, match=r"Cannot pay offer in different currency USD"): + l1.rpc.sendamount(offerusd, '100sat') + + def test_xpay_bip353(node_factory): fakebip353_plugin = Path(__file__).parent / "plugins" / "fakebip353.py" From 10ffa7c35c2fddcfb17471d026f893a769d5b56f Mon Sep 17 00:00:00 2001 From: Rusty Russell Date: Fri, 29 May 2026 18:58:17 +0930 Subject: [PATCH 2/3] pytest: test xpay for routehint when channel is to itself. Signed-off-by: Rusty Russell --- tests/test_xpay.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/test_xpay.py b/tests/test_xpay.py index 0f8475bc299d..99d910348dea 100644 --- a/tests/test_xpay.py +++ b/tests/test_xpay.py @@ -975,6 +975,33 @@ 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) + + # A routehint containing a same-node channel (example is l3 in this case) + circular_hint = [{'id': '03cecbfdc68544cc596223b68ce0710c9e5d2c9cb317ee07822d95079acc703d31', + 'short_channel_id': '1x2x3', + 'fee_base_msat': 0, + 'fee_proportional_millionths': 0, + 'cltv_expiry_delta': 6}, + {'id': '03cecbfdc68544cc596223b68ce0710c9e5d2c9cb317ee07822d95079acc703d31', + 'short_channel_id': '1x2x4', + 'fee_base_msat': 0, + 'fee_proportional_millionths': 0, + 'cltv_expiry_delta': 6}] + inv = l2.dev_invoice(amount_msat=10000, + label='circular_hint', + description='circular hint test', + dev_routes=[circular_hint]) + + # Payment should still succeed via the direct channel. + ret = l1.rpc.xpay(inv['bolt11']) + assert ret['successful_parts'] == 1 + l1.daemon.wait_for_log('Invoice gave bad self-node route') + + def test_xpay_currency_offer(node_factory): """Test that xpay and sendamount correctly report the currency name when rejecting non-msat offers.""" plugin = Path(__file__).parent / "plugins" / "currencyUSDAUD5000.py" From c436bbf889509704d7b61b19a20a95a9f5d38086 Mon Sep 17 00:00:00 2001 From: Rusty Russell Date: Fri, 29 May 2026 19:21:07 +0930 Subject: [PATCH 3/3] xpay: don't crash on circular routehints. We earlier fixed the cases of gossipd inserting a same-node channel, but didn't prevent it for local modifications: ``` cln-askrene: common/gossmap.c:52: nodeidx_htable_add: Assertion `!nodeidx_htable_getmatch_(ht, k, h, v, &i)' failed. cln-askrene: FATAL SIGNAL 6 (version v26.06rc2-5-gd389c3f-modded) 0x5c50e80dd5cb send_backtrace common/daemon.c:38 0x5c50e80dd685 crashdump common/daemon.c:83 0x70a5b1e4532f ??? ./signal/../sysdeps/unix/sysv/linux/x86_64/libc_sigaction.c:0 0x70a5b1e9eb2c __pthread_kill_implementation ./nptl/pthread_kill.c:44 0x70a5b1e9eb2c __pthread_kill_internal ./nptl/pthread_kill.c:78 0x70a5b1e9eb2c __GI___pthread_kill ./nptl/pthread_kill.c:89 0x70a5b1e4527d __GI_raise ../sysdeps/posix/raise.c:26 0x70a5b1e288fe __GI_abort ./stdlib/abort.c:79 0x70a5b1e2881a __assert_fail_base ./assert/assert.c:96 0x70a5b1e3b516 __assert_fail ./assert/assert.c:105 0x5c50e80dfb44 nodeidx_htable_add common/gossmap.c:52 0x5c50e80e1066 add_channel common/gossmap.c:515 0x5c50e80e327c gossmap_apply_localmods common/gossmap.c:1239 0x5c50e80bed55 do_getroutes plugins/askrene/askrene.c:620 0x5c50e80bf919 listpeerchannels_done ``` Reported-by: Won Hoi Kim and Ahmad Elmoursi Changelog-Fixed: Plugins: xpay no longer crashes on circular bolt11 routehints. Signed-off-by: Rusty Russell --- common/gossmap.c | 2 ++ common/gossmap.h | 1 + contrib/msggen/msggen/schema.json | 2 +- doc/schemas/askrene-create-channel.json | 2 +- plugins/askrene/askrene.c | 4 ++++ plugins/askrene/layer.c | 1 + plugins/askrene/layer.h | 2 +- plugins/xpay/xpay.c | 10 ++++++++++ tests/test_xpay.py | 1 - 9 files changed, 21 insertions(+), 4 deletions(-) diff --git a/common/gossmap.c b/common/gossmap.c index 78a885fcb231..38b57c19d29b 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 c8bfbf19fb42..9b34be9341e8 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 2424bdab68dd..846b79983b7c 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 a4256ae34fe0..809fdafe613d 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 490e1ce4cd34..736054ebab7b 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 610b3ce027d3..2c07ce77f523 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 547ed8da11ee..f70e39f0c31d 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 8510c59652bd..277f1a556eaa 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 99d910348dea..c6f8af0edf54 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)