xpay: add option to pay bip353.
What changed, and why it matters
This commit adds a new feature to Core Lightning's xpay plugin: the ability to pay a human-readable BIP353 address (like a Bitcoin Lightning email address). It is a straightforward feature addition with no visible security bug or vulnerability in the code changes.
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 extends the xpay RPC command to accept BIP353 names (strings containing ‘@’) by calling the existing fetchbip353 plugin, then validating and fetching the resulting BOLT12 offer via fetchinvoice. The offer validation logic is refactored into a helper (check_offer_payable) and reused for both direct BOLT12 offers and BIP353-resolved offers. Tests and schema documentation are updated accordingly.
Changed components
plugins/xpay/xpay.cdoc/schemas/xpay.jsoncontrib/msggen/msggen/schema.jsontests/test_xpay.pytests/plugins/fakebip353.pyInspect captured patch +152 / −38
diff --git a/contrib/msggen/msggen/schema.json b/contrib/msggen/msggen/schema.json
index 6844c033..a7e99dac 100644
--- a/contrib/msggen/msggen/schema.json
+++ b/contrib/msggen/msggen/schema.json
@@ -35529,7 +35529,7 @@
"invstring": {
"type": "string",
"description": [
- "bolt11 or bolt12 invoice, or a bolt12 (non-recursive) offer. If it's an offer, the invoice is fetch using `fetchinvoice` automatically."
+ "bolt11 or bolt12 invoice, a bolt12 (non-recursive) offer or a BIP353 name. If it's a bip353 name, an offer is fetched with `fetchbip353` if available. If it's an offer, the invoice is fetched using `fetchinvoice` automatically."
]
},
"amount_msat": {
diff --git a/doc/schemas/xpay.json b/doc/schemas/xpay.json
index 414e9a2c..0cafe43b 100644
--- a/doc/schemas/xpay.json
+++ b/doc/schemas/xpay.json
@@ -17,7 +17,7 @@
"invstring": {
"type": "string",
"description": [
- "bolt11 or bolt12 invoice, or a bolt12 (non-recursive) offer. If it's an offer, the invoice is fetch using `fetchinvoice` automatically."
+ "bolt11 or bolt12 invoice, a bolt12 (non-recursive) offer or a BIP353 name. If it's a bip353 name, an offer is fetched with `fetchbip353` if available. If it's an offer, the invoice is fetched using `fetchinvoice` automatically."
]
},
"amount_msat": {
diff --git a/plugins/xpay/xpay.c b/plugins/xpay/xpay.c
index 589229bc..16717ce3 100644
--- a/plugins/xpay/xpay.c
+++ b/plugins/xpay/xpay.c
@@ -1652,11 +1652,49 @@ preapproveinvoice_succeed(struct command *cmd,
return populate_private_layer(cmd, payment);
}
+static struct command_result *check_offer_payable(struct command *cmd,
+ const char *offerstr,
+ const struct amount_msat *msat)
+{
+ char *err;
+ struct tlv_offer *b12offer = offer_decode(tmpctx,
+ offerstr,
+ strlen(offerstr),
+ plugin_feature_set(cmd->plugin),
+ chainparams, &err);
+ if (!b12offer)
+ return command_fail(cmd, JSONRPC2_INVALID_PARAMS,
+ "Invalid bolt12 offer: %s", err);
+ /* 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",
+ b12offer->offer_currency);
+ if (b12offer->offer_amount) {
+ if (msat && !amount_msat_eq(amount_msat(*b12offer->offer_amount), *msat)) {
+ return command_fail(cmd, JSONRPC2_INVALID_PARAMS,
+ "Offer amount is %s, you tried to pay %s",
+ fmt_amount_msat(tmpctx, amount_msat(*b12offer->offer_amount)),
+ fmt_amount_msat(tmpctx, *msat));
+ }
+ } else {
+ if (!msat)
+ return command_fail(cmd, JSONRPC2_INVALID_PARAMS,
+ "Must specify amount for this offer");
+ }
+ if (b12offer->offer_recurrence)
+ return command_fail(cmd, JSONRPC2_INVALID_PARAMS,
+ "Cannot xpay recurring offers");
+
+ return NULL;
+}
+
struct xpay_params {
- struct amount_msat *maxfee, *partial;
+ struct amount_msat *msat, *maxfee, *partial;
const char **layers;
unsigned int retryfor;
u32 maxdelay, dev_maxparts;
+ const char *bip353;
};
static struct command_result *
@@ -1675,6 +1713,56 @@ invoice_fetched(struct command *cmd,
params->dev_maxparts, false);
}
+static struct command_result *
+do_fetchinvoice(struct command *cmd, const char *offerstr, struct xpay_params *xparams)
+{
+ struct out_req *req;
+
+ req = jsonrpc_request_start(cmd, "fetchinvoice",
+ invoice_fetched,
+ forward_error,
+ xparams);
+ json_add_string(req->js, "offer", offerstr);
+ if (xparams->msat)
+ json_add_amount_msat(req->js, "amount_msat", *xparams->msat);
+ if (xparams->bip353)
+ json_add_string(req->js, "bip353", xparams->bip353);
+ return send_outreq(req);
+}
+
+static struct command_result *
+bip353_fetched(struct command *cmd,
+ const char *method,
+ const char *buf,
+ const jsmntok_t *result,
+ struct xpay_params *xparams)
+{
+ const jsmntok_t *instructions, *t, *offertok;
+ const char *offerstr;
+ struct command_result *ret;
+ size_t i;
+
+ instructions = json_get_member(buf, result, "instructions");
+ json_for_each_arr(i, t, instructions) {
+ offertok = json_get_member(buf, t, "offer");
+ if (offertok)
+ break;
+ }
+
+ if (!offertok)
+ return command_fail(cmd, PAY_UNSPECIFIED_ERROR,
+ "BIP353 response did not contain an offer (%.*s)",
+ json_tok_full_len(result),
+ json_tok_full(buf, result));
+ offerstr = json_strdup(tmpctx, buf, offertok);
+
+ ret = check_offer_payable(cmd, offerstr, xparams->msat);
+ if (ret)
+ return ret;
+
+ return do_fetchinvoice(cmd, offerstr, xparams);
+}
+
static struct command_result *json_xpay_params(struct command *cmd,
const char *buffer,
const jsmntok_t *params,
@@ -1686,7 +1774,7 @@ static struct command_result *json_xpay_params(struct command *cmd,
u32 *maxdelay, *maxparts;
unsigned int *retryfor;
struct out_req *req;
- char *err;
+ struct xpay_params *xparams;
if (!param_check(cmd, buffer, params,
p_req("invstring", param_invstring, &invstring),
@@ -1706,53 +1794,44 @@ static struct command_result *json_xpay_params(struct command *cmd,
/* Is this a one-shot vibe payment? Kids these days! */
if (!as_pay && bolt12_has_offer_prefix(invstring)) {
- struct xpay_params *xparams;
- struct tlv_offer *b12offer = offer_decode(tmpctx,
- invstring,
- strlen(invstring),
- plugin_feature_set(cmd->plugin),
- chainparams, &err);
- if (!b12offer)
- return command_fail(cmd, JSONRPC2_INVALID_PARAMS,
- "Invalid bolt12 offer: %s", err);
- /* 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",
- b12offer->offer_currency);
- if (b12offer->offer_amount) {
- if (msat && !amount_msat_eq(amount_msat(*b12offer->offer_amount), *msat)) {
- return command_fail(cmd, JSONRPC2_INVALID_PARAMS,
- "Offer amount is %s, you tried to pay %s",
- fmt_amount_msat(tmpctx, amount_msat(*b12offer->offer_amount)),
- fmt_amount_msat(tmpctx, *msat));
- }
- } else {
- if (!msat)
- return command_fail(cmd, JSONRPC2_INVALID_PARAMS,
- "Must specify amount for this offer");
- }
- if (b12offer->offer_recurrence)
- return command_fail(cmd, JSONRPC2_INVALID_PARAMS,
- "Cannot xpay recurring offers");
+ struct command_result *ret;
+
+ ret = check_offer_payable(cmd, invstring, msat);
+ if (ret)
+ return ret;
if (command_check_only(cmd))
return command_check_done(cmd);
xparams = tal(cmd, struct xpay_params);
+ xparams->msat = msat;
xparams->maxfee = maxfee;
xparams->partial = partial;
xparams->layers = layers;
xparams->retryfor = *retryfor;
xparams->maxdelay = *maxdelay;
xparams->dev_maxparts = *maxparts;
+ xparams->bip353 = NULL;
+
+ return do_fetchinvoice(cmd, invstring, xparams);
+ }
- req = jsonrpc_request_start(cmd, "fetchinvoice",
- invoice_fetched,
+ /* BIP353? */
+ if (!as_pay && strchr(invstring, '@')) {
+ xparams = tal(cmd, struct xpay_params);
+ xparams->msat = msat;
+ xparams->maxfee = maxfee;
+ xparams->partial = partial;
+ xparams->layers = layers;
+ xparams->retryfor = *retryfor;
+ xparams->maxdelay = *maxdelay;
+ xparams->dev_maxparts = *maxparts;
+ xparams->bip353 = invstring;
+
+ req = jsonrpc_request_start(cmd, "fetchbip353",
+ bip353_fetched,
forward_error, xparams);
- json_add_string(req->js, "offer", invstring);
- if (msat)
- json_add_amount_msat(req->js, "amount_msat", *msat);
+ json_add_string(req->js, "address", invstring);
return send_outreq(req);
}
diff --git a/tests/plugins/fakebip353.py b/tests/plugins/fakebip353.py
new file mode 100755
index 00000000..d26b75eb
--- /dev/null
+++ b/tests/plugins/fakebip353.py
@@ -0,0 +1,21 @@
+#!/usr/bin/env python3
+"""
+This imitates fetchbip353 for testing.
+"""
+from pyln.client import Plugin
+
+plugin = Plugin()
+
+
+@plugin.method("fetchbip353")
+def fetchbip353(plugin, address, **kwargs):
+ return {'instructions': [{'offer': plugin.options['bip353offer']['value']}]}
+
+
+plugin.add_option(
+ 'bip353offer',
+ None,
+ "Fake offer to return"
+)
+
+plugin.run()
diff --git a/tests/test_xpay.py b/tests/test_xpay.py
index 822a26ff..ca464c80 100644
--- a/tests/test_xpay.py
+++ b/tests/test_xpay.py
@@ -996,3 +996,17 @@ def test_xpay_offer(node_factory):
l1.rpc.xpay(offer2)
l1.rpc.xpay(offer2, 5000)
+
+
+def test_xpay_bip353(node_factory):
+ fakebip353_plugin = Path(__file__).parent / "plugins" / "fakebip353.py"
+
+ l1 = node_factory.get_node()
+ offer = l1.rpc.offer('any')['bolt12']
+
+ l2 = node_factory.get_node(options={'disable-plugin': 'cln-bip353',
+ 'plugin': fakebip353_plugin,
+ 'bip353offer': offer})
+
+ node_factory.join_nodes([l2, l1])
+ l2.rpc.xpay('fake@fake.com', 100)
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.