From 6799dbe6568c7e4d2946db2d764dcf9e25f7633a Mon Sep 17 00:00:00 2001 From: Rusty Russell Date: Mon, 18 Aug 2025 11:01:03 +0930 Subject: [PATCH] xpay: add option to pay bip353. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changelog-Added: JSON-RPC: `xpay` can now directly pay a BIP353 address, like `â‚¿rusty@rustcorp.com.au`. Signed-off-by: Rusty Russell --- contrib/msggen/msggen/schema.json | 2 +- doc/schemas/xpay.json | 2 +- plugins/xpay/xpay.c | 151 +++++++++++++++++++++++------- tests/plugins/fakebip353.py | 21 +++++ tests/test_xpay.py | 14 +++ 5 files changed, 152 insertions(+), 38 deletions(-) create mode 100755 tests/plugins/fakebip353.py diff --git a/contrib/msggen/msggen/schema.json b/contrib/msggen/msggen/schema.json index 6844c033a..a7e99dac5 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 414e9a2c7..0cafe43bb 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 589229bcc..16717ce3d 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; - req = jsonrpc_request_start(cmd, "fetchinvoice", - invoice_fetched, + return do_fetchinvoice(cmd, invstring, xparams); + } + + /* 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 000000000..d26b75ebd --- /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 822a26ff1..ca464c801 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)