xpay: config option xpay-user-layer
What changed, and why it matters
This commit adds a new user-facing configuration option called xpay-user-layer for the xpay payment plugin in Core Lightning. It lets node operators permanently specify routing layers that xpay will apply to every payment, which is useful when xpay is handling the older pay command where layers cannot be passed directly. The change is a straightforward feature addition with no security-relevant behavior.
No security action required; review as normal feature code.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The patch introduces a new multi-string config option xpay-user-layer, stores the values in the xpay plugin state, and appends them to the layers array sent to getroutes. It also fixes a minor JSON formatting edge case in string_array_jsonfmt so that empty arrays are not emitted. A regression test verifies that configured layers disable expected channels and that removing the option restores normal payments.
Changed components
plugins/xpay/xpay.cplugins/libplugin.cdoc/lightningd-config.5.mdInspect captured patch +69 / −0
diff --git a/doc/lightningd-config.5.md b/doc/lightningd-config.5.md
index ece453d5..79d77fda 100644
--- a/doc/lightningd-config.5.md
+++ b/doc/lightningd-config.5.md
@@ -563,6 +563,10 @@ command, so they invoices can also be paid onchain.
Setting this makes `xpay` wait until all parts have failed/succeeded before returning. Usually this is unnecessary, as xpay will return on the first success (we have the preimage, if they don't take all the parts that's their problem) or failure (the destination could succeed another part, but it would mean it was only partially paid). The default is `false`.
+* **xpay-user-layer**=*name* [plugin `xpay`]
+
+ Specify the name of a layer `xpay` shall use always for every payment. This is specially useful when combined with `xpay-handle-pay` since the `layers` parameter is not available in the `pay` interface. This can be specified multiple times to add more layers.
+
* **askrene-timeout**=*SECONDS* [plugin `askrene`, *dynamic*]
This option makes the `getroutes` call fail if it takes more than this many seconds. Setting it to zero is a fun way to ensure your node never makes payments.
diff --git a/plugins/libplugin.c b/plugins/libplugin.c
index db2e53fb..861e7b4a 100644
--- a/plugins/libplugin.c
+++ b/plugins/libplugin.c
@@ -1731,6 +1731,8 @@ bool charp_jsonfmt(struct command *cmd, struct json_stream *js, const char *fiel
bool string_array_jsonfmt(struct command *cmd, struct json_stream *js,
const char *fieldname, const char ***arr)
{
+ if (tal_count(*arr) == 0)
+ return false;
json_array_start(js, fieldname);
for (size_t i = 0; i < tal_count(*arr); i++)
json_add_string(js, NULL, (*arr)[i]);
diff --git a/plugins/xpay/xpay.c b/plugins/xpay/xpay.c
index d7655f77..fab8ae45 100644
--- a/plugins/xpay/xpay.c
+++ b/plugins/xpay/xpay.c
@@ -51,6 +51,7 @@ struct xpay {
bool slow_mode;
/* Suppress calls to askrene-age */
bool dev_no_age;
+ const char **user_layers;
};
static struct xpay *xpay_of(struct plugin *plugin)
@@ -1828,6 +1829,8 @@ static struct command_result *getroutes_for(struct command *aux_cmd,
/* Add user-specified layers */
for (size_t i = 0; i < tal_count(payment->layers); i++)
json_add_string(req->js, NULL, payment->layers[i]);
+ for (size_t i = 0; i < tal_count(xpay->user_layers); i++)
+ json_add_string(req->js, NULL, xpay->user_layers[i]);
json_array_end(req->js);
json_add_amount_msat(req->js, "maxfee_msat", maxfee);
json_add_u32(req->js, "final_cltv", payment->final_cltv);
@@ -3172,6 +3175,7 @@ int main(int argc, char *argv[])
xpay->take_over_pay = true;
xpay->slow_mode = false;
xpay->dev_no_age = false;
+ xpay->user_layers = tal_arr(xpay, const char *, 0);
list_head_init(&xpay->payments);
plugin_main(argv, init, take(xpay),
PLUGIN_RESTARTABLE, true, NULL,
@@ -3185,6 +3189,9 @@ int main(int argc, char *argv[])
plugin_option_dynamic("xpay-slow-mode", "bool",
"Wait until all parts have completed before returning success or failure",
bool_option, bool_jsonfmt, &xpay->slow_mode),
+ plugin_option_multi("xpay-user-layer", "string",
+ "Add a layer that will be used for every payment",
+ multi_string_option, string_array_jsonfmt, &xpay->user_layers),
plugin_option_dev("dev-xpay-no-age", "flag",
"Don't call askrene-age",
flag_option, flag_jsonfmt, &xpay->dev_no_age),
diff --git a/tests/test_xpay.py b/tests/test_xpay.py
index 9373cc2f..967af1b1 100644
--- a/tests/test_xpay.py
+++ b/tests/test_xpay.py
@@ -1105,6 +1105,62 @@ def test_xpay_blockheight_mismatch(node_factory, bitcoind, executor):
fut.result(TIMEOUT)
+def test_xpay_user_layers(node_factory):
+ l1, l2, l3, l4 = node_factory.get_nodes(
+ 4, opts={"may_reconnect": True, "xpay-handle-pay": True}
+ )
+ node_factory.join_nodes([l1, l2, l3], wait_for_announce=True)
+ node_factory.join_nodes([l2, l4], wait_for_announce=True)
+
+ layer = "disable-chan23"
+ l1.rpc.askrene_create_layer(layer=layer, persistent=True)
+ scid = l2.rpc.listpeerchannels(l3.info["id"])["channels"][0]["short_channel_id"]
+ direction = l2.rpc.listpeerchannels(l3.info["id"])["channels"][0]["direction"]
+ l1.rpc.askrene_update_channel(
+ layer=layer, enabled=False, short_channel_id_dir=f"{scid}/{direction}"
+ )
+
+ layer = "disable-chan24"
+ l1.rpc.askrene_create_layer(layer=layer, persistent=True)
+ scid = l2.rpc.listpeerchannels(l4.info["id"])["channels"][0]["short_channel_id"]
+ direction = l2.rpc.listpeerchannels(l4.info["id"])["channels"][0]["direction"]
+ l1.rpc.askrene_update_channel(
+ layer=layer, enabled=False, short_channel_id_dir=f"{scid}/{direction}"
+ )
+
+ # Let us load these layers as user layers in xpay. Both payments should fail
+ l1.stop()
+ l1.daemon.opts["xpay-user-layer"] = ["disable-chan23", "disable-chan24"]
+ l1.start()
+ l1.rpc.connect(l2.info["id"], "localhost", l2.port)
+ l1.daemon.wait_for_log(f"channeld.*: billboard: Channel ready for use")
+ inv3 = l3.rpc.invoice(1000, "test-xpay-user-layer", "test-xpay-user-layer")[
+ "bolt11"
+ ]
+ with pytest.raises(
+ RpcError,
+ match="We could not find a usable set of paths. All 1 channels to the destination are disabled.",
+ ):
+ l1.rpc.pay(inv3)
+ inv4 = l4.rpc.invoice(1000, "test-xpay-user-layer", "test-xpay-user-layer")[
+ "bolt11"
+ ]
+ with pytest.raises(
+ RpcError,
+ match="We could not find a usable set of paths. All 1 channels to the destination are disabled.",
+ ):
+ l1.rpc.pay(inv4)
+
+ # Without those layers, the same payments should go through
+ l1.stop()
+ del l1.daemon.opts["xpay-user-layer"]
+ l1.start()
+ l1.rpc.connect(l2.info["id"], "localhost", l2.port)
+ l1.daemon.wait_for_log(f"channeld.*: billboard: Channel ready for use")
+ l1.rpc.pay(inv3)
+ l1.rpc.pay(inv4)
+
+
def test_xpay_get_error_with_update(node_factory):
"""We should process an update inside a temporary_channel_failure"""
l1, l2, l3 = node_factory.line_graph(3, opts={'log-level': 'io'}, fundchannel=True, wait_for_announce=True)
Why this scored 15/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.